diff --git a/docs/_includes/mesh-band.html b/docs/_includes/mesh-band.html index 37cfef4..a979d4a 100644 --- a/docs/_includes/mesh-band.html +++ b/docs/_includes/mesh-band.html @@ -15,7 +15,7 @@ data-distortion="{{ include.distortion | default: 0.8 }}" data-swirl="{{ include.swirl | default: 0.55 }}"> - +
{{ include.caption }} Field: Paper Shaders mesh gradient (Apache-2.0), palette and weights from this note. diff --git a/docs/assets/figures/mage-004/mesh-band.png b/docs/assets/figures/mage-004/mesh-band.png index 64a234a..325ef63 100644 Binary files a/docs/assets/figures/mage-004/mesh-band.png and b/docs/assets/figures/mage-004/mesh-band.png differ diff --git a/docs/assets/figures/mage-007/mesh-band.png b/docs/assets/figures/mage-007/mesh-band.png index 7298492..210cb28 100644 Binary files a/docs/assets/figures/mage-007/mesh-band.png and b/docs/assets/figures/mage-007/mesh-band.png differ diff --git a/docs/assets/mesh-band.js b/docs/assets/mesh-band.js index 98879a9..1eb4e75 100644 --- a/docs/assets/mesh-band.js +++ b/docs/assets/mesh-band.js @@ -30,6 +30,8 @@ uniform float u_grainMixer; uniform float u_grainOverlay; uniform float u_intensity; uniform float u_opacityGain; +uniform float u_saturation; +uniform vec2 u_pointer; in vec2 v_objectUV; out vec4 fragColor; @@ -105,7 +107,8 @@ void main() { for (int i = 0; i < 10; i++) { if (i >= int(u_colorsCount)) break; - vec2 pos = getPosition(i, t) + mixerGrain; + // The field leans toward the pointer: the nearer spot leads, the far ones trail. + vec2 pos = getPosition(i, t) + mixerGrain + u_pointer * (0.18 / (1.0 + float(i))); vec3 colorFraction = u_colors[i].rgb * u_colors[i].a; float opacityFraction = u_colors[i].a; @@ -127,6 +130,11 @@ void main() { color *= u_intensity; opacity *= u_opacityGain; + // Paper's shader averages the spots, which on a page of two or three colours + // drags everything toward grey. Push the result back away from its own luma so + // the band reads as colour rather than as a wash. + color = mix(vec3(dot(color, vec3(0.2126, 0.7152, 0.0722))), color, u_saturation); + if (u_grainOverlay > 0.) { float grainOverlay = valueNoise(rotate(grainUV, 1.) + vec2(3.)); grainOverlay = mix(grainOverlay, valueNoise(rotate(grainUV, 2.) + vec2(-1.)), .5); @@ -254,6 +262,8 @@ void main() { grainOverlay: gl.getUniformLocation(prog, "u_grainOverlay"), intensity: gl.getUniformLocation(prog, "u_intensity"), opacityGain: gl.getUniformLocation(prog, "u_opacityGain"), + saturation: gl.getUniformLocation(prog, "u_saturation"), + pointer: gl.getUniformLocation(prog, "u_pointer"), }; gl.uniform4fv(u.colors, spots); @@ -262,12 +272,18 @@ void main() { gl.uniform1f(u.swirl, Number(band.dataset.swirl ?? 0.55)); gl.uniform1f(u.grainMixer, Number(band.dataset.grainMixer ?? 0.05)); gl.uniform1f(u.grainOverlay, Number(band.dataset.grainOverlay ?? 0.04)); - gl.uniform1f(u.intensity, Number(band.dataset.intensity ?? 0.8)); - gl.uniform1f(u.opacityGain, Number(band.dataset.opacity ?? 0.6)); + gl.uniform1f(u.intensity, Number(band.dataset.intensity ?? 0.5)); + gl.uniform1f(u.opacityGain, Number(band.dataset.opacity ?? 0.85)); + gl.uniform1f(u.saturation, Number(band.dataset.saturation ?? 2.9)); + gl.uniform2f(u.pointer, 0, 0); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.clearColor(...BACKDROP); + // Pointer state: a direction the spots lean toward, eased into and out of. + const pointer = [0, 0]; + const pointerTarget = [0, 0]; + function resize() { const dpr = Math.min(window.devicePixelRatio || 1, 1.5); const width = Math.max(1, Math.round(canvas.clientWidth * dpr)); @@ -283,6 +299,10 @@ void main() { function frame(seconds) { resize(); + // The pointer pulls the field, then the field settles back on its own. + pointer[0] += (pointerTarget[0] - pointer[0]) * 0.07; + pointer[1] += (pointerTarget[1] - pointer[1]) * 0.07; + gl.uniform2f(u.pointer, pointer[0], pointer[1]); gl.clear(gl.COLOR_BUFFER_BIT); gl.uniform1f(u.time, seconds); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); @@ -333,6 +353,22 @@ void main() { window.addEventListener("resize", () => { if (reduced.matches) frame(0); }); + + // A mouse leans the field; lifts of the pointer let it settle back. Touch is + // left alone so the band never competes with scrolling. + if (!reduced.matches) { + band.addEventListener("pointermove", (event) => { + if (event.pointerType && event.pointerType !== "mouse") return; + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + pointerTarget[0] = ((event.clientX - rect.left) / rect.width - 0.5) * 2; + pointerTarget[1] = -((event.clientY - rect.top) / rect.height - 0.5) * 2; + }); + band.addEventListener("pointerleave", () => { + pointerTarget[0] = 0; + pointerTarget[1] = 0; + }); + } } function boot() { diff --git a/docs/assets/notebook.css b/docs/assets/notebook.css index 2c2f01a..110c80e 100644 --- a/docs/assets/notebook.css +++ b/docs/assets/notebook.css @@ -168,7 +168,7 @@ mjx-container[display] { overflow-x: auto; overflow-y: hidden; padding-block: 8p radial-gradient(90% 130% at 52% 92%, rgba(147, 202, 255, .16), transparent 58%), var(--paper); } -.mesh-band canvas { display: block; width: 100%; height: auto; aspect-ratio: 740 / 220; opacity: 0; transition: opacity .7s ease; } +.mesh-band canvas { display: block; width: 100%; height: auto; aspect-ratio: 740 / 220; max-height: 170px; opacity: 0; transition: opacity .7s ease; } .mesh-band[data-ready] canvas { opacity: 1; } .mesh-band noscript img { display: block; width: 100%; height: auto; } .mesh-band figcaption { padding: 10px 14px 12px; border-top: 1px solid var(--rule); background: var(--surface); font-size: 12px; line-height: 1.7; color: var(--muted); } diff --git a/docs/experiments/mage-004.md b/docs/experiments/mage-004.md index baa56db..38d0b6c 100644 --- a/docs/experiments/mage-004.md +++ b/docs/experiments/mage-004.md @@ -11,7 +11,7 @@ mesh_band: true weights="0.74,1,0.62,0.90" still="/assets/figures/mage-004/mesh-band.png" alt="A dark field with four soft spots of colour — blue, green, lavender and warm sand — scaled by how competitive each implementation is." - caption="The four spots are the four implementations in the table below, opacity set by the geometric mean of their kernel times relative to the best implementation on each operation: Triton brightest, cuTile Rust dimmest." %}**The claim.** Give a compiler the job of deciding how a GPU kernel places its data, and it will do + caption="The four spots are the four implementations, opacity set by how competitive each is." %}**The claim.** Give a compiler the job of deciding how a GPU kernel places its data, and it will do a good job where the reuse is low and a worse one where the reuse is high. On five FP32 operations, the [cuTile Rust](https://github.com/NVlabs/cutile-rs) tile kernels beat the hand-written ones on bias + GELU ($8.19\ \mu s$ against $11.0$), draw on layer normalization ($10.76$ against $10.05$), diff --git a/docs/experiments/mage-007.md b/docs/experiments/mage-007.md index 3530c61..b29e347 100644 --- a/docs/experiments/mage-007.md +++ b/docs/experiments/mage-007.md @@ -11,7 +11,7 @@ mesh_band: true weights="1.34,1.37,0.38" still="/assets/figures/mage-007/mesh-band.png" alt="A dark field with three soft spots of colour — green, blue and muted red — the red one much dimmer than the other two." - caption="The three spots are this note's three changes, opacity set by the speed-up each measured: neighbour aggregation (1.34×, green), LayerNorm (1.37×, blue) and the rejected GELU variant (0.38×, red)." %}**The claim.** Making each thread load four values at once instead of one is a memory decision, not + caption="The three spots are this note's three changes, opacity set by the speed-up each measured." %}**The claim.** Making each thread load four values at once instead of one is a memory decision, not an optimisation: it cuts the number of load instructions by four and puts $4\times$ the bytes in flight per instruction. It pays when a kernel is short of instructions to issue. It costs when the grid already fills the machine and the only thing hiding memory latency is how many threads are