render.js (8.3 KB)
1 let drawQueued = false; 2 function requestDraw() { 3 if (drawQueued) return; 4 drawQueued = true; 5 requestAnimationFrame(() => { 6 drawQueued = false; 7 draw(); 8 }); 9 } 10 11 function draw() { 12 const now = performance.now(); 13 if (outlineLightness !== outlineAnimTo) { 14 const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS; 15 if (t >= 1) { 16 outlineLightness = outlineAnimTo; 17 } else { 18 const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; 19 outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e; 20 requestDraw(); 21 } 22 } 23 if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) { 24 const t = (now - colorAnimStart) / OUTLINE_ANIM_MS; 25 if (t >= 1) { 26 dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB; 27 } else { 28 const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; 29 dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e); 30 dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e); 31 dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e); 32 requestDraw(); 33 } 34 } 35 36 const W = view.width, H = view.height; 37 vctx.setTransform(1, 0, 0, 1, 0, 0); 38 vctx.imageSmoothingEnabled = false; 39 vctx.fillStyle = '#000'; 40 vctx.fillRect(0, 0, W, H); 41 42 // one logical pixel on screen = integer device px. we round here so 43 // every logical pixel occupies the exact same number of device pixels: 44 // otherwise fractional dpr (1.25/1.5/1.75) makes nearest-neighbor 45 // resampling drop or duplicate rows, producing transparent stripes 46 // through painted content at high zoom. 47 const pxD = Math.max(1, Math.round(zoom * dpr)); 48 49 const viewWLog = cssW / zoom; 50 const viewHLog = cssH / zoom; 51 const wx0 = camX; 52 const wy0 = camY; 53 const wx1 = camX + viewWLog; 54 const wy1 = camY + viewHLog; 55 56 const cx0 = Math.floor(wx0 / CHUNK); 57 const cy0 = Math.floor(wy0 / CHUNK); 58 const cx1 = Math.floor((wx1 - 1e-9) / CHUNK); 59 const cy1 = Math.floor((wy1 - 1e-9) / CHUNK); 60 61 // round destinations to integer device pixels to avoid seams between 62 // adjacent chunks. compute right/bottom edges from the neighbor's 63 // rounded left/top so shared edges line up exactly. 64 const destX = (wx) => Math.round((wx - camX) * pxD); 65 const destY = (wy) => Math.round((wy - camY) * pxD); 66 67 for (let cy = cy0; cy <= cy1; cy++) { 68 for (let cx = cx0; cx <= cx1; cx++) { 69 const c = chunks.get(chunkKey(cx, cy)); 70 if (!c) continue; 71 const x0 = destX(cx * CHUNK); 72 const y0 = destY(cy * CHUNK); 73 const x1 = destX((cx + 1) * CHUNK); 74 const y1 = destY((cy + 1) * CHUNK); 75 vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0); 76 } 77 } 78 79 // our own strokes still waiting for their echo, composited over the frame 80 const overlay = net.overlays.get(chunks.id); 81 if (overlay) { 82 for (let cy = cy0; cy <= cy1; cy++) { 83 for (let cx = cx0; cx <= cx1; cx++) { 84 const c = overlay.get(chunkKey(cx, cy)); 85 if (!c) continue; 86 const x0 = destX(cx * CHUNK); 87 const y0 = destY(cy * CHUNK); 88 vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0); 89 } 90 } 91 } 92 93 // onionskin: ghost the frame behind us in playback order, screen-blended 94 // so black contributes nothing over the opaque chunks 95 if (onionskin && !playing && frames.length > 1) { 96 const ghost = frames[(frameIdx - dir + frames.length) % frames.length]; 97 const ghostOverlay = net.overlays.get(ghost.id); 98 vctx.save(); 99 vctx.globalAlpha = GHOST_ALPHA; 100 vctx.globalCompositeOperation = 'screen'; 101 for (const layer of ghostOverlay ? [ghost, ghostOverlay] : [ghost]) { 102 for (let cy = cy0; cy <= cy1; cy++) { 103 for (let cx = cx0; cx <= cx1; cx++) { 104 const c = layer.get(chunkKey(cx, cy)); 105 if (!c) continue; 106 const x0 = destX(cx * CHUNK); 107 const y0 = destY(cy * CHUNK); 108 vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0); 109 } 110 } 111 } 112 vctx.restore(); 113 } 114 115 if (cursorAlpha !== cursorFadeTo) { 116 const dur = cursorFadeTo > cursorFadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS; 117 const t = (now - cursorFadeStart) / dur; 118 if (t >= 1) { 119 cursorAlpha = cursorFadeTo; 120 if (cursorFadeTo === 0) mouseInside = false; 121 } else { 122 const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; 123 cursorAlpha = cursorFadeFrom + (cursorFadeTo - cursorFadeFrom) * e; 124 requestDraw(); 125 } 126 } 127 // peers first, so the local brush always sits on top of theirs. their 128 // fades run here rather than arriving over the wire, so a brush leaving 129 // a phone still eases out on everyone else's screen 130 for (const [id, p] of net.peers) { 131 if (p.alpha !== p.fadeTo) { 132 const dur = p.fadeTo > p.fadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS; 133 const t = (now - p.fadeStart) / dur; 134 if (t >= 1) { 135 p.alpha = p.fadeTo; 136 } else { 137 const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; 138 p.alpha = p.fadeFrom + (p.fadeTo - p.fadeFrom) * e; 139 requestDraw(); 140 } 141 } 142 if (p.alpha <= 0) { 143 if (p.fadeTo === 0) net.peers.delete(id); 144 continue; 145 } 146 if (!p.b) continue; 147 const off = Math.floor(p.b / 2); 148 const sx = Math.round((p.x - off - camX) * pxD); 149 const sy = Math.round((p.y - off - camY) * pxD); 150 if (sx > W || sy > H || sx + p.b * pxD < 0 || sy + p.b * pxD < 0) continue; 151 // someone standing on another frame is drawn the way that frame's 152 // own marks would be: ghosted, so their brush can't read as 153 // something on the frame you're actually painting 154 const ghost = p.f !== chunks.id; 155 const a = p.alpha * (ghost ? GHOST_ALPHA : 1); 156 drawCursorShape(sx, sy, p.b, p.s, p.c, p.c[0] + p.c[1] + p.c[2] > 384 ? 1 : 0, a, pxD, ghost); 157 } 158 159 if (mouseInside) { 160 const tl = brushTopLeft(curX, curY); 161 // round to integer device pixels - camX/camY are fractional (smooth 162 // pan), and fractional fillRect coordinates antialias their edges, 163 // which would leave transparent lines between adjacent row strips. 164 // during an active pan, anchor the cursor to the real client 165 // position (rounded only to device pixels) so it tracks smoothly 166 // instead of jittering as the logical-pixel floor flips back and 167 // forth under a fractional camera. when the pan ends the cursor 168 // snaps back to the logical-pixel grid via the idle timer below. 169 let sx, sy; 170 if (panning && curClientX !== null) { 171 const off = Math.floor(brush / 2); 172 sx = Math.round((curClientX - off * zoom) * dpr); 173 sy = Math.round((curClientY - off * zoom) * dpr); 174 } else { 175 sx = Math.round((tl.x - camX) * pxD); 176 sy = Math.round((tl.y - camY) * pxD); 177 } 178 drawCursorShape(sx, sy, brush, roundness, [dispR, dispG, dispB], outlineLightness, cursorAlpha, pxD); 179 } 180 181 net.flush(); 182 net.sendCursor(); 183 } 184 185 // a brush preview: solid fill in its own color, wrapped in a 186 // 1-logical-pixel outline cross-faded between darken and lighten. as a 187 // ghost it drops the outline and screens like onionskinned content, since 188 // that is exactly what it is - a brush on some other frame 189 function drawCursorShape(sx, sy, n, round, col, lightness, alpha, pxD, ghost) { 190 if (alpha <= 0) return; 191 const shape = round === 0 ? null : brushShapeFor(n, round, col[0], col[1], col[2]); 192 vctx.save(); 193 vctx.globalAlpha = alpha; 194 if (ghost) vctx.globalCompositeOperation = 'screen'; 195 vctx.fillStyle = 'rgb(' + col[0] + ',' + col[1] + ',' + col[2] + ')'; 196 if (!shape) { 197 vctx.fillRect(sx, sy, n * pxD, n * pxD); 198 } else { 199 const runs = shape.fillRuns; 200 for (let i = 0; i < runs.length; i += 3) { 201 vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD); 202 } 203 } 204 if (ghost) { 205 vctx.restore(); 206 return; 207 } 208 209 const drawOutline = () => { 210 if (shape) { 211 const runs = shape.outlineRuns; 212 for (let i = 0; i < runs.length; i += 3) { 213 vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD); 214 } 215 } else { 216 vctx.fillRect(sx - pxD, sy - pxD, (n + 2) * pxD, pxD); // top 217 vctx.fillRect(sx - pxD, sy + n * pxD, (n + 2) * pxD, pxD); // bottom 218 vctx.fillRect(sx - pxD, sy, pxD, n * pxD); // left 219 vctx.fillRect(sx + n * pxD, sy, pxD, n * pxD); // right 220 } 221 }; 222 vctx.save(); 223 if (lightness > 0) { 224 vctx.globalCompositeOperation = 'multiply'; 225 vctx.globalAlpha = lightness * alpha; 226 vctx.fillStyle = 'rgb(128,128,128)'; 227 drawOutline(); 228 } 229 if (lightness < 1) { 230 vctx.globalCompositeOperation = 'screen'; 231 vctx.globalAlpha = (1 - lightness) * alpha; 232 vctx.fillStyle = 'rgb(128,128,128)'; 233 drawOutline(); 234 } 235 vctx.restore(); 236 vctx.restore(); 237 }