From b975e728741e56bf0bee77ae13d5835c096cbe59 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 6 Apr 2026 01:28:09 +0200 Subject: [PATCH 1/4] refactor + lose state --- .../examples/rendering/suika-sdf/constants.ts | 13 + .../examples/rendering/suika-sdf/index.html | 66 ++++ .../src/examples/rendering/suika-sdf/index.ts | 326 +++++++++++------- .../examples/rendering/suika-sdf/physics.ts | 136 +++++++- .../examples/rendering/suika-sdf/schemas.ts | 3 + 5 files changed, 413 insertions(+), 131 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/suika-sdf/constants.ts b/apps/typegpu-docs/src/examples/rendering/suika-sdf/constants.ts index 47088fbc48..282dcb0333 100644 --- a/apps/typegpu-docs/src/examples/rendering/suika-sdf/constants.ts +++ b/apps/typegpu-docs/src/examples/rendering/suika-sdf/constants.ts @@ -9,6 +9,11 @@ export const WALL_DEFS = [ { cx: 0.5, cy: 0, hw: 0.05, hh: 0.55 }, { cx: -0.5, cy: 0, hw: 0.05, hh: 0.55 }, ]; +export const PHYSICS_WALL_DEFS = [ + { cx: 0, cy: -0.5, hw: 0.5, hh: 0.05 }, + { cx: 0.5, cy: 1.75, hw: 0.05, hh: 2.8 }, + { cx: -0.5, cy: 1.75, hw: 0.05, hh: 2.8 }, +]; export const SCENE_SCALE = 0.75; export const WALL_COLOR = d.vec3f(0.55, 0.5, 0.45); @@ -18,6 +23,14 @@ export const GHOST_ALPHA = 0.45; export const SMOOTH_MIN_K = 16.0; export const SHARP_FACTOR = 2.4; export const SPEED_BLEND_MAX = 0.5; +export const PULL_ACTIVATION_FACTOR = 2.0; +export const PULL_FORCE = 0.001; +export const LOSE_LINE_Y = 0.452; +export const LOSE_TIMEOUT_MS = 8_000; +export const WARNING_FLASH_SPEED = 0.01; +export const ESCAPE_X = 0.72; +export const ESCAPE_BOTTOM_Y = -0.85; +export const LOSE_LINE_HALF_THICKNESS = 0.005; export const MERGE_SCORES = Array.from({ length: LEVEL_COUNT }, (_, n) => ((n + 1) * (n + 2)) / 2); diff --git a/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.html b/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.html index ec4007a8f7..d50df83d4b 100644 --- a/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.html +++ b/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.html @@ -44,10 +44,76 @@ #attribution a:hover { text-decoration: underline; } + + #lose-screen { + position: absolute; + inset: 0; + z-index: 4; + display: grid; + place-items: center; + background: rgba(38, 14, 14, 0.55); + backdrop-filter: blur(6px); + } + + #lose-screen[hidden] { + display: none; + } + + #lose-card { + min-width: 15rem; + padding: 1.25rem 1.5rem; + border-radius: 1rem; + background: rgba(255, 247, 240, 0.94); + box-shadow: + 0 1rem 2.5rem rgba(0, 0, 0, 0.3), + inset 0 0 0 1px rgba(255, 255, 255, 0.65); + text-align: center; + color: #4d2216; + font-family: 'Fredoka One', cursive; + } + + #lose-card h2 { + margin: 0 0 0.625rem; + font-size: 2rem; + color: #c63d2f; + } + + #lose-score { + margin-bottom: 1rem; + font-size: 1.1rem; + } + + #reset-button { + border: 0; + border-radius: 999px; + padding: 0.65rem 1.2rem; + background: linear-gradient(180deg, #ffb45b, #ef7d32); + color: white; + font: inherit; + cursor: pointer; + box-shadow: 0 0.35rem 0 rgba(168, 78, 26, 0.9); + } + + #reset-button:hover { + filter: brightness(1.05); + } + + #reset-button:active { + transform: translateY(0.15rem); + box-shadow: 0 0.2rem 0 rgba(168, 78, 26, 0.9); + }
0
+ +
Assets by Apps2Amigos { attributionEl.style.opacity = '0'; attributionEl.style.pointerEvents = 'none'; }; -canvas.addEventListener('click', dismissAttribution, { once: true }); -canvas.addEventListener('touchend', dismissAttribution, { once: true }); +canvas.addEventListener('click', dismissAttribution, { once: true, signal }); +canvas.addEventListener('touchend', dismissAttribution, { once: true, signal }); +resetButtonEl.addEventListener('click', restart, { signal }); + +function triggerGameOver() { + if (isGameOver) { + return; + } + isGameOver = true; + finalScoreEl.textContent = String(score); + loseScreenEl.hidden = false; +} + +function computeDangerStrength(now: number, startTime: number | null): number { + if (startTime === null) { + return 0; + } + const elapsed = now - startTime; + const progress = Math.min(elapsed / LOSE_TIMEOUT_MS, 1); + const flash = 0.5 + 0.5 * Math.sin(now * WARNING_FLASH_SPEED); + return (0.3 + 0.7 * progress) * (0.35 + 0.65 * flash); +} + +function isFruitEscaped(state: BallState): boolean { + return Math.abs(state.pos.x) > ESCAPE_X || state.pos.y < ESCAPE_BOTTOM_Y; +} const { spriteAtlas, sdfAtlas, contours } = await createAtlases(); -const physics = await createPhysicsWorld(WALL_DEFS); +const physics = await createPhysicsWorld(PHYSICS_WALL_DEFS); const spriteTexture = root['~unstable'] .createTexture({ @@ -115,6 +151,11 @@ const linSampler = root['~unstable'].createSampler({ minFilter: 'linear', }); +const nearSampler = root['~unstable'].createSampler({ + magFilter: 'nearest', + minFilter: 'nearest', +}); + const smoothSdfReadView = createSmoothedSdf(root, sdfTexture, linSampler); const mergedFieldLayout = tgpu.bindGroupLayout({ @@ -156,7 +197,6 @@ const frameUniform = root.createUniform(Frame, { }); const circleData = Array.from({ length: MAX_FRUITS }, () => ({ ...INACTIVE_CIRCLE })); -const frameStates: (BallState | null)[] = []; const sampleSdf = (uv: d.v2f, radius: number, localPos: d.v2f, level: number) => { 'use gpu'; @@ -217,6 +257,25 @@ const applyGhost = (baseColor: d.v3f, ghost: d.Infer, scenePos: return std.mix(baseColor, spriteColor, alpha); }; +const applyDangerOverlay = (baseColor: d.v3f, scenePos: d.v2f, activeCount: number) => { + 'use gpu'; + let out = d.vec3f(baseColor); + for (let i = d.u32(0); i < activeCount; i++) { + const circle = circleUniform.$[i]; + if (circle.danger <= 0) { + continue; + } + const raw = (scenePos - circle.center) / (circle.radius * 2); + const localPos = rotate2d(raw, -circle.angle); + const clamped = clampRadial(localPos, MAX_LEVEL_RADIUS, MIN_RADIUS); + const uv = circleUv(clamped); + const dist = sampleSdf(uv, circle.radius, localPos, circle.level); + const fruitMask = std.smoothstep(std.fwidth(scenePos.x), 0, dist); + out = std.mix(out, d.vec3f(1, 0.12, 0.1), fruitMask * circle.danger * 0.78); + } + return out; +}; + const applyNextPreview = ( color: d.v3f, uv: d.v2f, @@ -247,6 +306,21 @@ const applyNextPreview = ( return std.mix(out, pvSprite.xyz, fruitAlpha); }; +const applyLoseLine = (baseColor: d.v3f, scenePos: d.v2f) => { + 'use gpu'; + const lineDist = std.abs(scenePos.y - LOSE_LINE_Y) - LOSE_LINE_HALF_THICKNESS; + const lineAa = std.max(std.fwidth(scenePos.y) * 0.35, 0.0009); + const lineMask = 1 - std.smoothstep(0.0, lineAa, lineDist); + const xMask = std.smoothstep( + PLAYFIELD_HALF_WIDTH - 0.02, + PLAYFIELD_HALF_WIDTH - 0.05, + std.abs(scenePos.x), + ); + const dashCell = std.abs(std.fract(scenePos.x * 12) - 0.5); + const dash = 1 - std.smoothstep(0.18, 0.22, dashCell); + return std.mix(baseColor, d.vec3f(1, 0.97, 0.92), lineMask * xMask * dash * 0.9); +}; + const mergedFieldPipeline = root.createRenderPipeline({ vertex: fullScreenTriangle, fragment: ({ uv }) => { @@ -318,7 +392,9 @@ const renderPipeline = root.createRenderPipeline({ bucketMask, ); - const field = std.textureSampleLevel(mergedFieldLayout.$.mergedField, linSampler.$, uv, 0); + const fieldLin = std.textureSampleLevel(mergedFieldLayout.$.mergedField, linSampler.$, uv, 0); + const fieldNear = std.textureSampleLevel(mergedFieldLayout.$.mergedField, nearSampler.$, uv, 0); + const field = d.vec4f(fieldLin.x, fieldNear.y, fieldNear.z, fieldNear.w); // Fruit glow on bucket interior back wall bg += blendSprite(d.vec2f(0.5), d.i32(field.w)) * @@ -328,7 +404,8 @@ const renderPipeline = root.createRenderPipeline({ const hit = evalWalls(scenePos, evalFruits(field, frame.activeCount), daylight); const alpha = std.smoothstep(std.fwidth(scenePos.x), 0, hit.dist); - const sceneColor = std.mix(bg, hit.color, alpha); + let sceneColor = std.mix(bg, hit.color, alpha); + sceneColor = applyDangerOverlay(sceneColor, scenePos, frame.activeCount); let finalColor = applyGhost(sceneColor, frame.ghostCircle, scenePos); finalColor = applyNextPreview( finalColor, @@ -339,10 +416,11 @@ const renderPipeline = root.createRenderPipeline({ daylight, frame.time, ); + finalColor = applyLoseLine(finalColor, scenePos); return d.vec4f(finalColor, 1); }, - targets: { format: presentationFormat }, + targets: { format: navigator.gpu.getPreferredCanvasFormat() }, }); const resizeObserver = new ResizeObserver(() => { @@ -355,9 +433,9 @@ const resizeObserver = new ResizeObserver(() => { }); resizeObserver.observe(canvas); -canvas.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false }); -canvas.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false }); -canvas.addEventListener('wheel', (e) => e.preventDefault(), { passive: false }); +canvas.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false, signal }); +canvas.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false, signal }); +canvas.addEventListener('wheel', (e) => e.preventDefault(), { passive: false, signal }); function pointerToSceneX(clientX: number, rect: DOMRect): number { const uvX = (clientX - rect.left) / rect.width; @@ -367,13 +445,24 @@ function pointerToSceneX(clientX: number, rect: DOMRect): number { return (((uvX - offsetX) / scaleX) * 2 - 1) * SCENE_SCALE; } -canvas.addEventListener('pointermove', (e) => { - const rect = canvas.getBoundingClientRect(); - const sceneX = pointerToSceneX(e.clientX, rect); - ghostX = clampSpawnX(sceneX, LEVEL_RADII[ghostLevel]); -}); +canvas.addEventListener( + 'pointermove', + (e) => { + if (isGameOver) { + return; + } + const rect = canvas.getBoundingClientRect(); + const sceneX = pointerToSceneX(e.clientX, rect); + ghostX = clampSpawnX(sceneX, LEVEL_RADII[ghostLevel]); + }, + { signal }, +); -function spawnAndAdvance(now: number) { +function trySpawn() { + const now = performance.now() * 0.001; + if (isGameOver || now - lastSpawnTime < SPAWN_COOLDOWN || activeFruits.length >= MAX_FRUITS) { + return; + } spawnFruit(ghostLevel, ghostX); lastSpawnTime = now; ghostLevel = nextGhostLevel; @@ -381,36 +470,25 @@ function spawnAndAdvance(now: number) { ghostX = clampSpawnX(ghostX, LEVEL_RADII[ghostLevel]); } -canvas.addEventListener('touchend', (e) => { - e.preventDefault(); - const touch = e.changedTouches[0]; - if (!touch) { - return; - } - const rect = canvas.getBoundingClientRect(); - ghostX = clampSpawnX(pointerToSceneX(touch.clientX, rect), LEVEL_RADII[ghostLevel]); - const now = performance.now() * 0.001; - if (now - lastSpawnTime < SPAWN_COOLDOWN || activeFruits.length >= MAX_FRUITS) { - return; - } - spawnAndAdvance(now); -}); - -canvas.addEventListener('click', () => { - const now = performance.now() * 0.001; - if (now - lastSpawnTime < SPAWN_COOLDOWN || activeFruits.length >= MAX_FRUITS) { - return; - } - spawnAndAdvance(now); -}); +canvas.addEventListener( + 'touchend', + (e) => { + e.preventDefault(); + if (isGameOver) { + return; + } + const touch = e.changedTouches[0]; + if (!touch) { + return; + } + const rect = canvas.getBoundingClientRect(); + ghostX = clampSpawnX(pointerToSceneX(touch.clientX, rect), LEVEL_RADII[ghostLevel]); + trySpawn(); + }, + { signal }, +); -function markDead(fruit: ActiveFruit) { - if (fruit.dead) { - return; - } - fruit.dead = true; - physics.removeBall(fruit.bodyIndex); -} +canvas.addEventListener('click', trySpawn, { signal }); function spawnFruit( level: number, @@ -430,65 +508,10 @@ function spawnFruit( dead: false, spawnTime: performance.now(), isMerge, + dangerStartTime: null, }); } -function pruneDead() { - activeFruits = activeFruits.filter((fruit) => !fruit.dead); -} - -function checkMerges() { - const maxLevel = LEVEL_COUNT - 1; - const count = activeFruits.length; - let merged = false; - for (let i = 0; i < count; i++) { - const a = activeFruits[i]; - if (a.dead || a.level >= maxLevel) { - continue; - } - const sa = frameStates[i]; - if (!sa) { - continue; - } - const mergeDist = a.radius * MERGE_DISTANCE_FACTOR; - - for (let j = i + 1; j < count; j++) { - const b = activeFruits[j]; - if (b.dead || a.level !== b.level) { - continue; - } - const sb = frameStates[j]; - if (!sb) { - continue; - } - - const dx = sa.pos.x - sb.pos.x; - const dy = sa.pos.y - sb.pos.y; - if (dx * dx + dy * dy < mergeDist * mergeDist) { - markDead(a); - markDead(b); - score += MERGE_SCORES[a.level]; - scoreEl.textContent = String(score); - spawnFruit( - a.level + 1, - (sa.pos.x + sb.pos.x) * 0.5, - (sa.pos.y + sb.pos.y) * 0.5, - (sa.vel.x + sb.vel.x) * 0.5, - (sa.vel.y + sb.vel.y) * 0.5, - Math.atan2( - Math.sin(sa.angle) + Math.sin(sb.angle), - Math.cos(sa.angle) + Math.cos(sb.angle), - ), - true, - ); - merged = true; - break; - } - } - } - return merged; -} - function restart() { for (const fruit of activeFruits) { if (!fruit.dead) { @@ -496,13 +519,15 @@ function restart() { } } activeFruits = []; - frameStates.length = 0; ghostLevel = randomLevel(); nextGhostLevel = randomLevel(); ghostX = 0; lastSpawnTime = -Infinity; + isGameOver = false; score = 0; scoreEl.textContent = '0'; + finalScoreEl.textContent = '0'; + loseScreenEl.hidden = true; for (let i = 0; i < MAX_FRUITS; i++) { circleData[i] = { ...INACTIVE_CIRCLE }; } @@ -510,29 +535,64 @@ function restart() { } let lastTime = 0; +let animationFrameId = 0; +let isDisposed = false; function frame(now: number) { + if (isDisposed) { + return; + } const dt = Math.min((now - lastTime) / 1000, 0.05); lastTime = now; - physics.step(dt); - frameStates.length = activeFruits.length; + const merges = physics.step( + dt, + MERGE_DISTANCE_FACTOR, + PULL_ACTIVATION_FACTOR, + PULL_FORCE, + LEVEL_COUNT - 1, + ); + + if (!isGameOver) { + for (const m of merges) { + for (const f of activeFruits) { + if (f.bodyIndex === m.handleA || f.bodyIndex === m.handleB) { + f.dead = true; + } + } + score += MERGE_SCORES[m.level]; + scoreEl.textContent = String(score); + spawnFruit(m.level + 1, m.x, m.y, m.vx, m.vy, m.angle, true); + } + } let drawCount = 0; for (let i = 0; i < activeFruits.length; i++) { const f = activeFruits[i]; if (f.dead) { - frameStates[i] = null; continue; } const state = physics.getBallState(f.bodyIndex); - if (!state || Math.abs(state.pos.x) > OFFSCREEN || state.pos.y < -OFFSCREEN) { - frameStates[i] = null; - markDead(f); + if (!state) { + f.dead = true; continue; } - frameStates[i] = state; + + if (!isGameOver) { + if (isFruitEscaped(state)) { + triggerGameOver(); + } + + if (state.pos.y > LOSE_LINE_Y) { + f.dangerStartTime ??= now; + if (now - f.dangerStartTime >= LOSE_TIMEOUT_MS) { + triggerGameOver(); + } + } else { + f.dangerStartTime = null; + } + } if (drawCount >= MAX_FRUITS) { continue; @@ -540,6 +600,7 @@ function frame(now: number) { let speed = Math.min(Math.sqrt(state.vel.x ** 2 + state.vel.y ** 2) / SPEED_BLEND_MAX, 1); let visualRadius = f.radius; + const danger = computeDangerStrength(now, f.dangerStartTime); if (f.isMerge) { const t = Math.min((now - f.spawnTime) / 500, 1); @@ -555,11 +616,16 @@ function frame(now: number) { level: f.level, angle: state.angle, speed, + danger, }; drawCount++; } - bgTime = (bgTime + dt * timeScale + 1000) % 1000; + activeFruits = activeFruits.filter((f) => !f.dead); + + if (!isGameOver) { + bgTime = (bgTime + dt * timeScale + 1000) % 1000; + } circleUniform.write(circleData); frameUniform.write({ @@ -567,13 +633,16 @@ function frame(now: number) { canvasAspect: canvas.width / canvas.height, activeCount: drawCount, nextLevel: nextGhostLevel, - ghostCircle: { - center: d.vec2f(ghostX, DROP_Y), - radius: LEVEL_RADII[ghostLevel], - level: ghostLevel, - angle: 0, - speed: 0, - }, + ghostCircle: isGameOver + ? INACTIVE_CIRCLE + : { + center: d.vec2f(ghostX, DROP_Y), + radius: LEVEL_RADII[ghostLevel], + level: ghostLevel, + angle: 0, + speed: 0, + danger: 0, + }, }); mergedFieldPipeline.withColorAttachment({ view: mergedFieldView }).draw(3); @@ -583,12 +652,9 @@ function frame(now: number) { .withColorAttachment({ view: context, clearValue: { r: 0, g: 0, b: 0, a: 1 } }) .draw(3); - checkMerges(); - pruneDead(); - - requestAnimationFrame(frame); + animationFrameId = requestAnimationFrame(frame); } -requestAnimationFrame(frame); +animationFrameId = requestAnimationFrame(frame); export const controls = defineControls({ Restart: { @@ -604,3 +670,11 @@ export const controls = defineControls({ }, }, }); + +export function onCleanup() { + isDisposed = true; + cleanupController.abort(); + cancelAnimationFrame(animationFrameId); + resizeObserver.disconnect(); + root.destroy(); +} diff --git a/apps/typegpu-docs/src/examples/rendering/suika-sdf/physics.ts b/apps/typegpu-docs/src/examples/rendering/suika-sdf/physics.ts index 3ab08ed9dd..f533e057dd 100644 --- a/apps/typegpu-docs/src/examples/rendering/suika-sdf/physics.ts +++ b/apps/typegpu-docs/src/examples/rendering/suika-sdf/physics.ts @@ -1,13 +1,30 @@ -import RAPIER, { type Vector2 } from '@dimforge/rapier2d-compat'; +import RAPIER from '@dimforge/rapier2d-compat'; export interface BallState { - pos: Vector2; - vel: Vector2; + pos: { x: number; y: number }; + vel: { x: number; y: number }; + angle: number; +} + +export interface MergeEvent { + handleA: number; + handleB: number; + level: number; + x: number; + y: number; + vx: number; + vy: number; angle: number; } export interface PhysicsWorld { - step(dt: number): void; + step( + dt: number, + mergeDistFactor: number, + pullActivationFactor: number, + pullForce: number, + maxLevel: number, + ): MergeEvent[]; getBallState(handle: number): BallState | null; addBall( x: number, @@ -44,11 +61,118 @@ export async function createPhysicsWorld( } const ballBodies: (RAPIER.RigidBody | null)[] = []; + const ballLevels: number[] = []; + const ballRadii: number[] = []; + + function applyPullAssist( + mergeDistFactor: number, + pullActivationFactor: number, + pullForce: number, + ) { + const count = ballBodies.length; + for (let i = 0; i < count; i++) { + const bodyA = ballBodies[i]; + if (!bodyA) { + continue; + } + const levelA = ballLevels[i]; + const radiusA = ballRadii[i]; + const mergeDist = radiusA * mergeDistFactor; + const pullStart = mergeDist * pullActivationFactor; + const mergeDistSq = mergeDist * mergeDist; + const pullStartSq = pullStart * pullStart; + const pullRange = pullStart - mergeDist; + const posA = bodyA.translation(); + const velA = bodyA.linvel(); + + for (let j = i + 1; j < count; j++) { + const bodyB = ballBodies[j]; + if (!bodyB || ballLevels[j] !== levelA) { + continue; + } + + const posB = bodyB.translation(); + const dx = posB.x - posA.x; + const dy = posB.y - posA.y; + const distSq = dx * dx + dy * dy; + if (distSq <= mergeDistSq || distSq >= pullStartSq) { + continue; + } + const dist = Math.sqrt(distSq); + const nx = dx / dist; + const ny = dy / dist; + + const velB = bodyB.linvel(); + const dvx = velB.x - velA.x; + const dvy = velB.y - velA.y; + if (-(dvx * nx + dvy * ny) > 0.05) { + continue; + } + + const impulse = (1 - (dist - mergeDist) / pullRange) * pullForce; + bodyA.applyImpulse({ x: nx * impulse, y: ny * impulse }, true); + bodyB.applyImpulse({ x: -nx * impulse, y: -ny * impulse }, true); + } + } + } + + function detectMerges(mergeDistFactor: number, maxLevel: number): MergeEvent[] { + const merges: MergeEvent[] = []; + const count = ballBodies.length; + for (let i = 0; i < count; i++) { + const bodyA = ballBodies[i]; + if (!bodyA || ballLevels[i] >= maxLevel) { + continue; + } + const levelA = ballLevels[i]; + const mergeDist = ballRadii[i] * mergeDistFactor; + const mergeDistSq = mergeDist * mergeDist; + const posA = bodyA.translation(); + + for (let j = i + 1; j < count; j++) { + const bodyB = ballBodies[j]; + if (!bodyB || ballLevels[j] !== levelA) { + continue; + } + const posB = bodyB.translation(); + const dx = posA.x - posB.x; + const dy = posA.y - posB.y; + if (dx * dx + dy * dy >= mergeDistSq) { + continue; + } + + const velA = bodyA.linvel(); + const velB = bodyB.linvel(); + const angA = bodyA.rotation(); + const angB = bodyB.rotation(); + + merges.push({ + handleA: i, + handleB: j, + level: levelA, + x: (posA.x + posB.x) * 0.5, + y: (posA.y + posB.y) * 0.5, + vx: (velA.x + velB.x) * 0.5, + vy: (velA.y + velB.y) * 0.5, + angle: Math.atan2(Math.sin(angA) + Math.sin(angB), Math.cos(angA) + Math.cos(angB)), + }); + + world.removeRigidBody(bodyA); + world.removeRigidBody(bodyB); + ballBodies[i] = null; + ballBodies[j] = null; + break; + } + } + return merges; + } return { - step(dt: number) { + step(dt, mergeDistFactor, pullActivationFactor, pullForce, maxLevel) { + applyPullAssist(mergeDistFactor, pullActivationFactor, pullForce); world.timestep = dt; world.step(); + return detectMerges(mergeDistFactor, maxLevel); }, getBallState(handle: number): BallState | null { @@ -82,6 +206,8 @@ export async function createPhysicsWorld( ); ballBodies.push(body); + ballLevels.push(level); + ballRadii.push(radius); return ballBodies.length - 1; }, diff --git a/apps/typegpu-docs/src/examples/rendering/suika-sdf/schemas.ts b/apps/typegpu-docs/src/examples/rendering/suika-sdf/schemas.ts index a9ada06b6e..9ffe2f686a 100644 --- a/apps/typegpu-docs/src/examples/rendering/suika-sdf/schemas.ts +++ b/apps/typegpu-docs/src/examples/rendering/suika-sdf/schemas.ts @@ -9,6 +9,7 @@ export const SdCircle = d.struct({ level: d.i32, angle: d.f32, speed: d.f32, + danger: d.f32, }); export const INACTIVE_CIRCLE = { @@ -17,6 +18,7 @@ export const INACTIVE_CIRCLE = { level: 0, angle: 0, speed: 0, + danger: 0, }; export const Frame = d.struct({ @@ -36,6 +38,7 @@ export interface ActiveFruit { dead: boolean; spawnTime: number; isMerge: boolean; + dangerStartTime: number | null; } export const LEVEL_F32_ZEROS = Array.from({ length: LEVEL_COUNT }, () => 0); From 0b6460f5bbec964f0b6c6b6483c28e5babddc9e8 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 6 Apr 2026 02:18:58 +0200 Subject: [PATCH 2/4] cleaner --- .../examples/rendering/suika-sdf/constants.ts | 2 +- .../src/examples/rendering/suika-sdf/index.ts | 99 ++++++++++--------- 2 files changed, 54 insertions(+), 47 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/suika-sdf/constants.ts b/apps/typegpu-docs/src/examples/rendering/suika-sdf/constants.ts index 282dcb0333..678c4e307f 100644 --- a/apps/typegpu-docs/src/examples/rendering/suika-sdf/constants.ts +++ b/apps/typegpu-docs/src/examples/rendering/suika-sdf/constants.ts @@ -41,5 +41,5 @@ export const SPAWN_WEIGHTS = [4, 3, 2, 1]; export const SPAWN_WEIGHT_TOTAL = SPAWN_WEIGHTS.reduce((a, b) => a + b, 0); export const MERGE_DISTANCE_FACTOR = 0.4; export const PLAYFIELD_HALF_WIDTH = 0.5; -export const SPAWN_COOLDOWN = 0.35; +export const SPAWN_COOLDOWN = 0.0035; export const GAME_ASPECT = 1; diff --git a/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts b/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts index 77e0f18b36..a9f7a79ca5 100644 --- a/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts @@ -159,20 +159,28 @@ const nearSampler = root['~unstable'].createSampler({ const smoothSdfReadView = createSmoothedSdf(root, sdfTexture, linSampler); const mergedFieldLayout = tgpu.bindGroupLayout({ - mergedField: { texture: d.texture2d() }, + distance: { texture: d.texture2d() }, + info: { texture: d.texture2d() }, }); function createMergedFieldResources() { const size = [canvas.width, canvas.height].map((v) => Math.ceil(v / 2)) as [number, number]; - return root['~unstable'] - .createTexture({ size, format: 'rgba16float' }) - .$usage('sampled', 'render'); + return { + distance: root['~unstable'] + .createTexture({ size, format: 'r16float' }) + .$usage('sampled', 'render'), + info: root['~unstable'] + .createTexture({ size, format: 'rgba16float' }) + .$usage('sampled', 'render'), + }; } -let mergedFieldTexture = createMergedFieldResources(); -let mergedFieldView = mergedFieldTexture.createView(d.texture2d()); +let mergedField = createMergedFieldResources(); +let distanceView = mergedField.distance.createView(d.texture2d()); +let infoView = mergedField.info.createView(d.texture2d()); let mergedFieldBindGroup = root.createBindGroup(mergedFieldLayout, { - mergedField: mergedFieldView, + distance: distanceView, + info: infoView, }); const rectUniform = root.createUniform( @@ -218,15 +226,14 @@ const blendSprite = (uv: d.v2f, level: number) => { return std.mix(center, sprite.xyz, sprite.w); }; -const evalFruits = (field: d.v4f, activeCount: number) => { +const evalFruits = (bestDist: number, info: d.v4f, activeCount: number) => { 'use gpu'; if (activeCount === 0) { return SceneHit({ dist: 2e10, color: d.vec3f() }); } - return SceneHit({ - dist: field.x, - color: blendSprite(field.yz, d.i32(field.w)), - }); + let color = blendSprite(info.xy, d.i32(info.z)); + color = std.mix(color, d.vec3f(1, 0.12, 0.1), info.w * 0.78); + return SceneHit({ dist: bestDist, color }); }; const evalWalls = (scenePos: d.v2f, hit: d.Infer, daylight: number) => { @@ -257,25 +264,6 @@ const applyGhost = (baseColor: d.v3f, ghost: d.Infer, scenePos: return std.mix(baseColor, spriteColor, alpha); }; -const applyDangerOverlay = (baseColor: d.v3f, scenePos: d.v2f, activeCount: number) => { - 'use gpu'; - let out = d.vec3f(baseColor); - for (let i = d.u32(0); i < activeCount; i++) { - const circle = circleUniform.$[i]; - if (circle.danger <= 0) { - continue; - } - const raw = (scenePos - circle.center) / (circle.radius * 2); - const localPos = rotate2d(raw, -circle.angle); - const clamped = clampRadial(localPos, MAX_LEVEL_RADIUS, MIN_RADIUS); - const uv = circleUv(clamped); - const dist = sampleSdf(uv, circle.radius, localPos, circle.level); - const fruitMask = std.smoothstep(std.fwidth(scenePos.x), 0, dist); - out = std.mix(out, d.vec3f(1, 0.12, 0.1), fruitMask * circle.danger * 0.78); - } - return out; -}; - const applyNextPreview = ( color: d.v3f, uv: d.v2f, @@ -336,6 +324,7 @@ const mergedFieldPipeline = root.createRenderPipeline({ let smoothAccum = d.arrayOf(d.f32, LEVEL_COUNT)(LEVEL_F32_ZEROS); let uvAccum = d.arrayOf(d.vec2f, LEVEL_COUNT)(LEVEL_V2F_ZEROS); let uvWeight = d.arrayOf(d.f32, LEVEL_COUNT)(LEVEL_F32_ZEROS); + let dangerAccum = d.arrayOf(d.f32, LEVEL_COUNT)(LEVEL_F32_ZEROS); for (let i = d.u32(0); i < frame.activeCount; i++) { const circle = circleUniform.$[i]; @@ -354,8 +343,10 @@ const mergedFieldPipeline = root.createRenderPipeline({ smoothAccum[lv] += weight; uvAccum[lv] += uvLocal * weight; uvWeight[lv] += weight; + dangerAccum[lv] += circle.danger * weight; } + let bestDanger = d.f32(0); for (let level = d.i32(0); level < LEVEL_COUNT; level++) { const safeSmooth = std.max(smoothAccum[level], 1e-6); const dist = -std.log(safeSmooth) / SMOOTH_MIN_K; @@ -364,12 +355,19 @@ const mergedFieldPipeline = root.createRenderPipeline({ bestDist = dist; bestLevel = d.f32(level); bestUv = d.vec2f(blendedUv); + bestDanger = dangerAccum[level] / std.max(uvWeight[level], 1e-6); } } - return d.vec4f(bestDist, bestUv.x, bestUv.y, bestLevel); + return { + distance: d.vec4f(bestDist, 0, 0, 0), + info: d.vec4f(bestUv, bestLevel, bestDanger), + }; + }, + targets: { + distance: { format: 'r16float' }, + info: { format: 'rgba16float' }, }, - targets: { format: 'rgba16float' }, }); const renderPipeline = root.createRenderPipeline({ @@ -392,20 +390,18 @@ const renderPipeline = root.createRenderPipeline({ bucketMask, ); - const fieldLin = std.textureSampleLevel(mergedFieldLayout.$.mergedField, linSampler.$, uv, 0); - const fieldNear = std.textureSampleLevel(mergedFieldLayout.$.mergedField, nearSampler.$, uv, 0); - const field = d.vec4f(fieldLin.x, fieldNear.y, fieldNear.z, fieldNear.w); + const bestDist = std.textureSampleLevel(mergedFieldLayout.$.distance, linSampler.$, uv, 0).x; + const info = std.textureSampleLevel(mergedFieldLayout.$.info, nearSampler.$, uv, 0); // Fruit glow on bucket interior back wall bg += - blendSprite(d.vec2f(0.5), d.i32(field.w)) * - std.exp(-std.max(field.x, 0) * 12) * + blendSprite(d.vec2f(0.5), d.i32(info.z)) * + std.exp(-std.max(bestDist, 0) * 12) * bucketMask * 0.4; - const hit = evalWalls(scenePos, evalFruits(field, frame.activeCount), daylight); + const hit = evalWalls(scenePos, evalFruits(bestDist, info, frame.activeCount), daylight); const alpha = std.smoothstep(std.fwidth(scenePos.x), 0, hit.dist); let sceneColor = std.mix(bg, hit.color, alpha); - sceneColor = applyDangerOverlay(sceneColor, scenePos, frame.activeCount); let finalColor = applyGhost(sceneColor, frame.ghostCircle, scenePos); finalColor = applyNextPreview( finalColor, @@ -420,15 +416,17 @@ const renderPipeline = root.createRenderPipeline({ return d.vec4f(finalColor, 1); }, - targets: { format: navigator.gpu.getPreferredCanvasFormat() }, }); const resizeObserver = new ResizeObserver(() => { - mergedFieldTexture.destroy(); - mergedFieldTexture = createMergedFieldResources(); - mergedFieldView = mergedFieldTexture.createView(d.texture2d()); + mergedField.distance.destroy(); + mergedField.info.destroy(); + mergedField = createMergedFieldResources(); + distanceView = mergedField.distance.createView(d.texture2d()); + infoView = mergedField.info.createView(d.texture2d()); mergedFieldBindGroup = root.createBindGroup(mergedFieldLayout, { - mergedField: mergedFieldView, + distance: distanceView, + info: infoView, }); }); resizeObserver.observe(canvas); @@ -610,6 +608,10 @@ function frame(now: number) { speed = Math.max(speed, 1 - t * t); } + if (visualRadius <= MIN_RADIUS) { + continue; + } + circleData[drawCount] = { center: d.vec2f(state.pos.x, state.pos.y), radius: visualRadius, @@ -645,7 +647,12 @@ function frame(now: number) { }, }); - mergedFieldPipeline.withColorAttachment({ view: mergedFieldView }).draw(3); + mergedFieldPipeline + .withColorAttachment({ + distance: { view: distanceView }, + info: { view: infoView }, + }) + .draw(3); renderPipeline .with(mergedFieldBindGroup) From 32681ea4894066ef517d0a65a1daed72517d769a Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 6 Apr 2026 02:42:42 +0200 Subject: [PATCH 3/4] tweaks --- .../examples/rendering/suika-sdf/constants.ts | 6 ++-- .../src/examples/rendering/suika-sdf/index.ts | 2 +- .../examples/rendering/suika-sdf/physics.ts | 33 +++++++------------ 3 files changed, 16 insertions(+), 25 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/suika-sdf/constants.ts b/apps/typegpu-docs/src/examples/rendering/suika-sdf/constants.ts index 678c4e307f..8eb9225359 100644 --- a/apps/typegpu-docs/src/examples/rendering/suika-sdf/constants.ts +++ b/apps/typegpu-docs/src/examples/rendering/suika-sdf/constants.ts @@ -23,8 +23,8 @@ export const GHOST_ALPHA = 0.45; export const SMOOTH_MIN_K = 16.0; export const SHARP_FACTOR = 2.4; export const SPEED_BLEND_MAX = 0.5; -export const PULL_ACTIVATION_FACTOR = 2.0; -export const PULL_FORCE = 0.001; +export const PULL_ACTIVATION_FACTOR = 2.2; +export const PULL_FORCE = 0.0015; export const LOSE_LINE_Y = 0.452; export const LOSE_TIMEOUT_MS = 8_000; export const WARNING_FLASH_SPEED = 0.01; @@ -41,5 +41,5 @@ export const SPAWN_WEIGHTS = [4, 3, 2, 1]; export const SPAWN_WEIGHT_TOTAL = SPAWN_WEIGHTS.reduce((a, b) => a + b, 0); export const MERGE_DISTANCE_FACTOR = 0.4; export const PLAYFIELD_HALF_WIDTH = 0.5; -export const SPAWN_COOLDOWN = 0.0035; +export const SPAWN_COOLDOWN = 0.35; export const GAME_ASPECT = 1; diff --git a/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts b/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts index a9f7a79ca5..3ef42e33fb 100644 --- a/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts @@ -560,7 +560,7 @@ function frame(now: number) { } score += MERGE_SCORES[m.level]; scoreEl.textContent = String(score); - spawnFruit(m.level + 1, m.x, m.y, m.vx, m.vy, m.angle, true); + spawnFruit(m.level + 1, m.pos.x, m.pos.y, m.vel.x, m.vel.y, m.angle, true); } } diff --git a/apps/typegpu-docs/src/examples/rendering/suika-sdf/physics.ts b/apps/typegpu-docs/src/examples/rendering/suika-sdf/physics.ts index f533e057dd..6cea5ec9e2 100644 --- a/apps/typegpu-docs/src/examples/rendering/suika-sdf/physics.ts +++ b/apps/typegpu-docs/src/examples/rendering/suika-sdf/physics.ts @@ -1,8 +1,8 @@ -import RAPIER from '@dimforge/rapier2d-compat'; +import RAPIER, { type Vector2 } from '@dimforge/rapier2d-compat'; export interface BallState { - pos: { x: number; y: number }; - vel: { x: number; y: number }; + pos: Vector2; + vel: Vector2; angle: number; } @@ -10,10 +10,8 @@ export interface MergeEvent { handleA: number; handleB: number; level: number; - x: number; - y: number; - vx: number; - vy: number; + pos: Vector2; + vel: Vector2; angle: number; } @@ -76,8 +74,7 @@ export async function createPhysicsWorld( continue; } const levelA = ballLevels[i]; - const radiusA = ballRadii[i]; - const mergeDist = radiusA * mergeDistFactor; + const mergeDist = ballRadii[i] * mergeDistFactor; const pullStart = mergeDist * pullActivationFactor; const mergeDistSq = mergeDist * mergeDist; const pullStartSq = pullStart * pullStart; @@ -150,10 +147,8 @@ export async function createPhysicsWorld( handleA: i, handleB: j, level: levelA, - x: (posA.x + posB.x) * 0.5, - y: (posA.y + posB.y) * 0.5, - vx: (velA.x + velB.x) * 0.5, - vy: (velA.y + velB.y) * 0.5, + pos: { x: (posA.x + posB.x) * 0.5, y: (posA.y + posB.y) * 0.5 }, + vel: { x: (velA.x + velB.x) * 0.5, y: (velA.y + velB.y) * 0.5 }, angle: Math.atan2(Math.sin(angA) + Math.sin(angB), Math.cos(angA) + Math.cos(angB)), }); @@ -175,19 +170,15 @@ export async function createPhysicsWorld( return detectMerges(mergeDistFactor, maxLevel); }, - getBallState(handle: number): BallState | null { + getBallState(handle) { const body = ballBodies[handle]; if (!body) { return null; } - return { - pos: body.translation(), - vel: body.linvel(), - angle: body.rotation(), - }; + return { pos: body.translation(), vel: body.linvel(), angle: body.rotation() }; }, - addBall(x, y, radius, contour, level, vx = 0, vy = 0, angle = 0): number { + addBall(x, y, radius, contour, level, vx = 0, vy = 0, angle = 0) { const body = world.createRigidBody( RAPIER.RigidBodyDesc.dynamic().setTranslation(x, y).setLinvel(vx, vy).setRotation(angle), ); @@ -211,7 +202,7 @@ export async function createPhysicsWorld( return ballBodies.length - 1; }, - removeBall(handle: number) { + removeBall(handle) { const body = ballBodies[handle]; if (body) { world.removeRigidBody(body); From d6fc15b046b3c64600d145ca8599ec606d059740 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Tue, 7 Apr 2026 16:15:58 +0200 Subject: [PATCH 4/4] long live signal --- apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts b/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts index 3ef42e33fb..55dcffd38a 100644 --- a/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/suika-sdf/index.ts @@ -534,10 +534,9 @@ function restart() { let lastTime = 0; let animationFrameId = 0; -let isDisposed = false; function frame(now: number) { - if (isDisposed) { + if (signal.aborted) { return; } const dt = Math.min((now - lastTime) / 1000, 0.05); @@ -679,7 +678,6 @@ export const controls = defineControls({ }); export function onCleanup() { - isDisposed = true; cleanupController.abort(); cancelAnimationFrame(animationFrameId); resizeObserver.disconnect();