Skip to content

Commit a2cb9e8

Browse files
RuitingMaclaude
andcommitted
Sketch perf pass: DPR caps, integer hash, single-sample caustics
- LobbySketch: cap DPR at 1.5, clamp dt on tab-refocus, hoist strokeStyle/lineWidth out of the ring loop (globalAlpha for per-ring opacity) to kill per-frame rgba() string allocations - SmokeSketch: grid 26k→18k cells, swap sin-based hash for integer xorshift-style (Math.imul) — ~576k sin/frame gone - ClinamenSketch: cap DPR at 1.5 (fragment shader is the bottleneck) - clinamen.frag.glsl: collapse 3 position-shifted laplacians into one 5-tap stencil with per-channel scale — 19 surfaceHeight() calls/pixel down to 5, dispersion was invisible at final caustic blend anyway - Small copy fixes: trailing period on Clinamen summary, date 04-19→04-20 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a2e7053 commit a2cb9e8

6 files changed

Lines changed: 56 additions & 45 deletions

File tree

src/components/ClinamenSketch.astro

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,9 @@
346346
// --- Resize ---
347347
let aspect = 1.0;
348348
function resize() {
349-
const dpr = Math.min(window.devicePixelRatio || 1, 2);
349+
// Capped at 1.5 (not the usual 2) — fragment shader is the bottleneck
350+
// and water ripple at native 2× buys ~no visual fidelity over 1.5×.
351+
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
350352
const rect = canvas!.getBoundingClientRect();
351353
const w = Math.max(1, Math.floor(rect.width * dpr));
352354
const h = Math.max(1, Math.floor(rect.height * dpr));

src/components/LobbySketch.astro

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
if (!canvas) return;
2222

2323
const ctx = canvas.getContext('2d')!;
24-
const dpr = window.devicePixelRatio || 1;
24+
// Cap at 1.5: this sketch is pure 2D canvas (cheap), but on 4K displays
25+
// at DPR=2 the fillRect per frame + arc strokes at 4× pixel area add up
26+
// to enough GC/raster churn to cause visible hitches on integrated GPUs.
27+
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
2528
let w = 0, h = 0;
2629

2730
function resize() {
@@ -103,20 +106,21 @@
103106
{ cx: w * 0.68, cy: h * 0.66, age: 0.62 },
104107
{ cx: w * 0.52, cy: h * 0.22, age: 0.18 },
105108
];
109+
ctx.strokeStyle = '#E6E2D6';
110+
ctx.lineWidth = 0.7;
106111
for (const r of frozen) {
107112
for (let k = 0; k < RING_COUNT; k++) {
108113
const ringAge = r.age - k * RING_STAGGER;
109114
if (ringAge <= 0 || ringAge >= 1) continue;
110115
const eased = easeOut(ringAge);
111116
const radius = eased * maxR;
112-
const alpha = Math.sin(ringAge * Math.PI) * 0.34 * (1 - k * 0.18);
113-
ctx.strokeStyle = `rgba(230, 226, 214, ${alpha})`;
114-
ctx.lineWidth = 0.7;
117+
ctx.globalAlpha = Math.sin(ringAge * Math.PI) * 0.34 * (1 - k * 0.18);
115118
ctx.beginPath();
116119
ctx.arc(r.cx, r.cy, radius, 0, Math.PI * 2);
117120
ctx.stroke();
118121
}
119122
}
123+
ctx.globalAlpha = 1;
120124
currentCleanup = () => {
121125
aborted = true;
122126
window.removeEventListener('stage:heat', onHeat);
@@ -133,7 +137,12 @@
133137
rafId = requestAnimationFrame(frame);
134138

135139
const now = performance.now();
136-
const dt = now - lastNow;
140+
// Cap dt at 100ms so returning from a backgrounded tab doesn't dump
141+
// `t` forward by seconds — which would (a) burst-spawn a pile of
142+
// ripples in one frame via the while-loop below, and (b) age every
143+
// active ripple past its lifetime instantly.
144+
let dt = now - lastNow;
145+
if (dt > 100) dt = 100;
137146
lastNow = now;
138147
if (!paused) t += dt;
139148

@@ -148,6 +157,12 @@
148157
nextSpawnAt = t + gap;
149158
}
150159

160+
// Stroke color is constant; per-ring alpha goes through globalAlpha
161+
// so we aren't allocating a fresh `rgba(...)` string each ring each
162+
// frame (~1000/s on a busy frame) — those were the main GC hitches.
163+
ctx.strokeStyle = '#E6E2D6';
164+
ctx.lineWidth = 0.7;
165+
151166
for (let i = ripples.length - 1; i >= 0; i--) {
152167
const r = ripples[i];
153168
const baseAge = (t - r.born) / LIFETIME;
@@ -164,13 +179,13 @@
164179
const radius = eased * maxR * r.strength;
165180
const alpha = Math.sin(ringAge * Math.PI) * 0.34 * (1 - k * 0.18) * r.strength;
166181

167-
ctx.strokeStyle = `rgba(230, 226, 214, ${alpha})`;
168-
ctx.lineWidth = 0.7;
182+
ctx.globalAlpha = alpha;
169183
ctx.beginPath();
170184
ctx.arc(r.cx, r.cy, radius, 0, Math.PI * 2);
171185
ctx.stroke();
172186
}
173187
}
188+
ctx.globalAlpha = 1;
174189
}
175190
frame();
176191

src/components/SmokeSketch.astro

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@
3333
let w = 0, h = 0;
3434

3535
// Grid resolution, preserving canvas aspect ratio. Recomputed on
36-
// resize. The offscreen buffer is reallocated to match.
37-
const TARGET_CELLS = 26000;
36+
// resize. The offscreen buffer is reallocated to match. 18k cells
37+
// (was 26k) — the upscale blit hides the coarser grid, and both
38+
// passes (velocity + advection) scale linearly in cell count.
39+
const TARGET_CELLS = 18000;
3840
let GW = 0, GH = 0;
3941
let cellW = 0, cellH = 0;
4042

@@ -83,9 +85,16 @@
8385
}
8486
resize();
8587

88+
// Integer hash (xorshift-style) — replaces the previous sin-based hash.
89+
// Called ~32×/cell on the velocity pass (fbm → noise → hash), so at
90+
// 18k cells that's ~576k Math.sin calls/frame. Integer ops are 5-10×
91+
// faster in V8. `| 0` floors into int32; the shifts and multiplies
92+
// stay in SMI range until the final normalize.
8693
const hash = (x: number, y: number) => {
87-
const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
88-
return s - Math.floor(s);
94+
let h = ((x | 0) * 374761393 + (y | 0) * 668265263) | 0;
95+
h = Math.imul(h ^ (h >>> 13), 1274126177);
96+
h = h ^ (h >>> 16);
97+
return (h >>> 0) / 4294967295;
8998
};
9099
const smoothstep = (t: number) => t * t * (3 - 2 * t);
91100
const noise = (x: number, y: number) => {

src/pages/index.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const sketches = [
88
title: 'Clinamen',
99
date: '2026-04-20',
1010
kind: 'AUDIO · GENERATIVE',
11-
summary: '河灯散布于水面',
11+
summary: '河灯散布于水面',
1212
},
1313
{
1414
slug: 'smoke',

src/pages/sketches/clinamen.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import ClinamenSketch from '../../components/ClinamenSketch.astro';
1010
<p class="breadcrumb enter" style="--i: 0;"><a href="/" class="writ">← 眠海</a></p>
1111

1212
<h1 class="enter" style="--i: 1;">Clinamen</h1>
13-
<p class="meta enter" style="--i: 2;">2026-04-19 · AUDIO · GENERATIVE</p>
13+
<p class="meta enter" style="--i: 2;">2026-04-20 · AUDIO · GENERATIVE</p>
1414

1515
<p class="enter" style="--i: 3;">
1616
两千年前 Lucretius 替 Epicurus 翻译过一个词——clinamen,“偶微偏”。所以世界啊,你被如此设想:你诞生于一次次平行下落的虚空里原子的不可预测偏斜。没有偏斜,就不会有碰撞;没有碰撞,就不存在世界。

src/shaders/clinamen.frag.glsl

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -104,44 +104,29 @@ float surfaceHeight(vec2 p, float t) {
104104
return h;
105105
}
106106

107-
vec2 surfaceGradient(vec2 p, float t) {
108-
float l = surfaceHeight(p - vec2(EPS, 0.0), t);
109-
float r = surfaceHeight(p + vec2(EPS, 0.0), t);
110-
float d = surfaceHeight(p - vec2(0.0, EPS), t);
111-
float u = surfaceHeight(p + vec2(0.0, EPS), t);
112-
return vec2(r - l, u - d) / (2.0 * EPS);
113-
}
114-
115-
float surfaceLaplacian(vec2 p, float t) {
116-
float c = surfaceHeight(p, t);
117-
float l = surfaceHeight(p - vec2(EPS, 0.0), t);
118-
float r = surfaceHeight(p + vec2(EPS, 0.0), t);
119-
float d = surfaceHeight(p - vec2(0.0, EPS), t);
120-
float u = surfaceHeight(p + vec2(0.0, EPS), t);
121-
return (l + r + d + u - 4.0 * c) / (EPS * EPS);
122-
}
123-
124107
void main() {
125108
vec2 aspect = vec2(u_resolution.x / u_resolution.y, 1.0);
126109
vec2 uv = vec2(v_uv.x, 1.0 - v_uv.y);
127110
vec2 p = uv * aspect;
128111
float t = u_time;
129112

130-
// ---- Caustics with dispersion ----
131-
// Caustic strength cut hard — earlier passes had the pool lit up with
132-
// bright bands even in rest, which fought the lantern halos and made
133-
// the water feel "processed". Now the caustic is a subtle whisper on
134-
// the water surface; you read it more as "the water is alive" than
135-
// as a distinct optical feature. Per-channel scales roughly halved
136-
// from the previous tune.
137-
vec2 g = surfaceGradient(p, t);
138-
float lapR = surfaceLaplacian(p + g * 0.005, t);
139-
float lapG = surfaceLaplacian(p, t);
140-
float lapB = surfaceLaplacian(p - g * 0.005, t);
113+
// ---- Caustics (single-sample laplacian, per-channel scale) ----
114+
// Previous version sampled 3 position-shifted laplacians for chromatic
115+
// dispersion — 19 surfaceHeight() calls per pixel, each looping through
116+
// all active ripples. Collapsed to one 5-tap stencil (4x cheaper at
117+
// 24-ripple load) and kept chromatic flavor via per-channel multiplier
118+
// only. The position-shift dispersion was invisible once caustic blend
119+
// dropped to 0.18 anyway.
120+
float hC = surfaceHeight(p, t);
121+
float hL = surfaceHeight(p - vec2(EPS, 0.0), t);
122+
float hR = surfaceHeight(p + vec2(EPS, 0.0), t);
123+
float hD = surfaceHeight(p - vec2(0.0, EPS), t);
124+
float hU = surfaceHeight(p + vec2(0.0, EPS), t);
125+
float lap = (hL + hR + hD + hU - 4.0 * hC) / (EPS * EPS);
141126
vec3 causticPattern = clamp(vec3(
142-
1.0 - lapR * 0.0013,
143-
1.0 - lapG * 0.0014,
144-
1.0 - lapB * 0.0016
127+
1.0 - lap * 0.0013,
128+
1.0 - lap * 0.0014,
129+
1.0 - lap * 0.0016
145130
), 0.0, 2.0);
146131

147132
// ---- Base water ----

0 commit comments

Comments
 (0)