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 }