diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index e215161..23b75e9 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -162,6 +162,13 @@ The frame loop applies speed and queued steps while keeping the per-frame tick l - On software GL it falls back to pixels, unless `window.__allowSoftwareGL` is set (the test tools set it). +The pixel look can use WebGPU (`src/render/gpu/`): whole-world textures receive visible +strided uploads, `colour.js` colours plain grains, and `draw.js` selects rare CPU override +blocks and submits colour, bloom and drawing. `cell.js` shares the original CPU cell rules +between Legacy/Smooth and those overrides; `objects.js` adds bars, flyers and blueprint ghosts. +The GPU canvas sits behind the transparent game canvas, which draws all existing overlays. +CPU lighting stays in `light.js`. `SET.renderer` selects WebGPU or Legacy; Smooth uses WebGL2. + ## The browser shell - `GAME` (`src/ui/state.js`) holds the UI state: tool, selected piece, build tab, drag, @@ -406,7 +413,37 @@ Screw casing geometry shared by both looks, ghosts and placement previews. smoot - functions: diagonalScrewJoins, straightScrewJoins, diagonalScrewShape, diagonalScrewBlock - variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap -### src/render/world.js (419 lines) +### src/render/cell.js (272 lines) +Shared CPU colour rules, used by legacy and the rare-cell WebGPU override. +- functions: cellPixel + +### src/render/objects.js (80 lines) +Loose bars, flying grains and queued blueprint blocks in the grain buffer. +- functions: worldObjects + +### src/render/gpu/colour.js (82 lines) +The plain-cell rules mirror cellPixel; built geometry stays in the CPU override. +- variables: GPU_COLOUR + +### src/render/gpu/shaders.js (42 lines) +Half-resolution separable Gaussian bloom, then nearest colour + linear light. +- variables: GPU_BLUR, GPU_DRAW + +### src/render/gpu/device.js (43 lines) +Async startup shares the title screen's adapter; failures leave a usable legacy renderer. +- functions: gpuInit, gpuFail, worldPixelContext +- variables: GPU + +### src/render/gpu/textures.js (84 lines) +Whole-world allocation, direct strided uploads; no cell packing or dirty tracking. +- functions: gpuTexture, gpuWrite, gpuBuffer, gpuWorldTextures, gpuViewTextures, gpuBindings, gpuUpload + +### src/render/gpu/draw.js (69 lines) +Only exceptional blocks and their geometric neighbours run the CPU colour rules. +- functions: gpuOverrides, gpuDraw, gpuPresent +- variables: GPU_PATTERN + +### src/render/world.js (83 lines) Scale colour without changing the smooth-look class. - functions: fogColor, drawWorld @@ -442,7 +479,7 @@ Game shell (src/ui/): browser only. This file: settings, the GAME state object, - functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus - variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $ -### src/ui/title.js (74 lines) +### src/ui/title.js (76 lines) Startup capabilities and title art. - functions: checkCapabilities, renderCapabilityNotice, canStartGame, buildTitleCells, drawTitle - variables: REQUIRE_GPU_AND_THREADS, CAPS, CAPS_CHECK, FONT5, TITLE @@ -461,7 +498,7 @@ Shared player state; the world save remains a complete standalone fallback. Starting, loading and quitting a game; modal dialogs; the pause menu. - functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds -### src/ui/settings.js (125 lines) +### src/ui/settings.js (126 lines) The settings screen. - functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe - variables: SET_TABS, setTab @@ -585,6 +622,7 @@ The frame loop and boot. - `test/t_flow.js`: Throughput overlay (t49): grains per second per transport block, counted only while the overlay is on, never changing what the simulation does. - `test/t_fog.js`: Fog: surface, sight, range, saves and simulation independence. - `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player. +- `test/t_gpu.py`: WebGPU pixel parity, rare-cell selection, world replacement, resize and device loss. - `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables. - `test/t_light.js`: Run the renderer's lighting against real simulation data, with only canvas storage stubbed. - `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state. @@ -641,3 +679,4 @@ The frame loop and boot. - `tools/scenes/t35-joins.js`: Run after clear-sky.js. Paused, empty pipes make casing, flights and mouths easy to inspect. - `tools/scenes/t36-funnel.js`: A twelve-block funnel, one block open at the bottom, draining 400 grains of sand. - `tools/scenes/t40-sloped-belt.js`: Self-contained: a flat feed climbs, crosses the top, and runs downhill; a filter sorts below. +- `tools/scenes/t88-renderer.js`: Paused comparison scene: ordinary grains, fog, heat, trees and exceptional geometry. diff --git a/docs/TESTING.md b/docs/TESTING.md index 9421640..ea08e3d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -110,6 +110,15 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \ `tools/scenes/clear-sky.js` clears a flat work area and sets `FLOOR`, `BX`, `BY` and `CAM`. Set `CAM = [x, y]` to frame the shot. - The HUD and the cursor previews are hidden unless you pass `--hud` or `--previews`. +- `--renderer webgpu|legacy` selects the pixel renderer (default WebGPU). WebGPU screenshots + fail clearly when no device is available; Smooth still uses WebGL2. +- `test/t_gpu.py` compares the rendered composite against Legacy for ordinary cells, built pieces, + ghosts, fog and all thermal palettes, and checks world replacement, resize, timing and device loss. + World-pixel tests use `worldPixelContext()` to read both stacked canvases. The helper + presents the retained GPU result again before copying, because canvas textures expire after + presentation ([WebGPU specification](https://gpuweb.github.io/gpuweb/#abstract-opdef-expire-the-current-texture), + [Mozilla implementation notes](https://bugzilla.mozilla.org/show_bug.cgi?id=1965377)). Tests that change + static `noiseB` in place set `GPU.world = null` before drawing to upload their new fixture. - `--look smooth` for the smooth look. Test in the pixel look unless you are checking graphics. - `--freeze` draws the last frame at a fixed time and frame count, so animations are caught at the same moment. Two shots of the same scene then match pixel for pixel, and a before / after @@ -193,7 +202,7 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \ simulation work per frame and number of ticks. p50/p95/p99 use interpolated sorted samples; five warm-up frames are excluded. Zero-tick frames correctly have zero simulation time. JSON also records the smooth renderer's adaptive resolution scale: compare it as well as time. - Comparisons require identical options, print all timing changes, and mark >10% regressions; + Comparisons accept subsets of the baseline scenes, looks and zooms, with identical world/viewport dimensions and run duration, print all timing changes, and mark >10% regressions; tiny timings and tails can be noisy, and a zero baseline has no meaningful percent change. Performance runs launch Chromium with @@ -212,3 +221,15 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \ - `tools/experiments/factory_cost.js`: what running factories cost the simulation. - `tools/experiments/flow.js`: belt and screw conveyor throughput in grains a second. - `tools/experiments/worldgen.js`: material counts and timings of world generation. + +WebGPU Phase 2a: Colour measures the CPU override fill (including loose objects and ghosts). +`frame_ms` is the complete paused-frame CPU call (including overlays and UI). +Upload measures strided visible-array writes and the override/light uploads, and Blit measures +GPU command submission. `override_cells` records the selected CPU cells; GPU timing is a +completed timestamp sample for that configuration across colour, bloom and drawing when `timestamp-query` is +available. The timestamp excludes uploads and the CPU lighting pass. Lighting remains on the +CPU. The GPU canvas draws the world; the transparent game canvas holds the existing overlays. + +The comparison fixture is `tools/scenes/t88-renderer.js`. Capture with `--freeze --zoom 12` +(and 6 or 2), `--viewport 1920x1080`, and each `--renderer`. It includes terrain, water, fire, +magma, fog, a tree, flat/slope belts, screws, pipe, mold, bar and blueprint ghost. diff --git a/docs/agent-log.md b/docs/agent-log.md index d565f51..67e10ae 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,22 @@ What agents have done, newest first. Add an entry for every piece of work: the d note or request it was for, what changed, the files touched, the tests run, and anything left open or not verified. Keep each entry short; the git history has the details. +## 2026-10-27: t88 phase 2a, WebGPU pixel look (Claude Code + Codex) + +- New `src/render/gpu/` (device, textures, colour compute, glow blur, draw) renders the pixel look + on a `#gpu` canvas under `#game`, which becomes a transparent overlay in WebGPU mode. The GPU + colours plain cells (background, materials with noise and grain, loose shade, fire, water, + ALPHA, digp shake, incandescence, lens, fog); the CPU colours only rare cells (built blocks, + screw/slope neighbours, bars, flyers, ghosts) through the extracted per-cell code + (`src/render/cell.js`, `src/render/objects.js`). World arrays upload per visible rectangle with + `queue.writeTexture`; light is still `computeLight` on the CPU, multiplied on the GPU. + `SET.renderer` = webgpu | legacy (Settings). The smooth look still uses the WebGL2 path. +- Screenshots legacy vs WebGPU: mean abs RGB error 0.112 / 0.062 / 0.034 at zoom 12/6/2, no pixel + off by more than 8. Factory scene drawWorld at zoom 2: 24.55 -> 9.98 ms (overrides 1.81, uploads + 2.39, CPU light 5.59; GPU time 0.06 ms). Real loop zoom 2 CPU p50/p95: 31.7/35.3 -> 15.4/16.7 ms. +- Tests: `python3 test/run_all.py --browser` all passed (new `test/t_gpu.py`). Not checked: + Firefox WebGPU (no adapter in Playwright Firefox on Linux). + ## 2026-10-27: t88 phase 1, serving and capability checks (Claude Code + Codex) - The game must be served with COOP/COEP (README has nginx and Caddy lines; opening from disk is diff --git a/src/manifest.json b/src/manifest.json index efdbd41..8629a82 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -48,6 +48,13 @@ "render/light.js", "render/slope-belts.js", "render/diagonal-screws.js", + "render/cell.js", + "render/objects.js", + "render/gpu/colour.js", + "render/gpu/shaders.js", + "render/gpu/device.js", + "render/gpu/textures.js", + "render/gpu/draw.js", "render/world.js", "render/machines.js", "render/overlays.js", diff --git a/src/render/cell.js b/src/render/cell.js new file mode 100644 index 0000000..b0d0fe3 --- /dev/null +++ b/src/render/cell.js @@ -0,0 +1,272 @@ +// Shared CPU colour rules, used by legacy and the rare-cell WebGPU override. +function cellPixel(x, y) { + const { bw, bh, px, vx0, vy0, smoothOn: sm, rain: rk } = R; + const bx = x - vx0, by = y - vy0, row = by * bw; + if (R.gpuOn && !px[row + bx]) GPU.overrideCount++; + const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask; + const OUT = sm ? 0x04000000 : 0xff000000; + if (x < 0 || y < 0 || x >= W || y >= H) { + px[row + bx] = OUT; + if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } + return; + } + const useFog = SET.fog, grain = SET.grain, incand = SET.incandescence; + const anim = R.frame, lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno; + const glowOn = SET.glow !== 'off' && !lens, gpx = R.glowPx, gw = R.glowC.width; + const fy = y / FOG, fj = Math.floor(fy), ft = fy - fj, fr = fj * FW, fn = Math.min(FH - 1, fj + 1) * FW; + const yw = y * W; + let skr = SKY_R[y], skg = SKY_G[y], skb = SKY_B[y]; + if (rk > 0.01) { const gy = (skr + skg + skb) / 3 * 0.82; skr += (gy - skr) * rk * 0.7; skg += (gy - skg) * rk * 0.7; skb += (gy - skb) * rk * 0.65; } + let fv = 255; + if (useFog) { + const fx = x / FOG, j = Math.floor(fx), t = fx - j, j1 = Math.min(FW - 1, j + 1); + const a = fog[fr + j], b = fog[fr + j1], c = fog[fn + j], d = fog[fn + j1]; + if ((a & b & c & d) !== 255) fv = (a + (b - a) * t) * (1 - ft) + (c + (d - c) * t) * ft; + if (!fv) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } return; } + // ease the fade into black (smoothstep) and break up the half-resolution steps with grain + // noise that is strongest mid-fade and nothing at either end, so the outer edge has no rim + if (fv < 255) { + let s = fv / 255; + s = Math.min(1, Math.max(0, s + (noiseB[yw + x] - 128) * (0.6 / 128) * s * (1 - s))); + fv = Math.max(1, 255 * s * s * (3 - 2 * s)); + } + } + const i = yw + x, m = mat[i], nz = noiseB[i] - 128; + const T = temp[i]; + // Also used by the thermal lens, which always draws in pixels. + let looseShade = 1; + if (!sm && m !== EMPTY && !builtForLight(i, x, y)) { + if (PH[m] === PH_POWDER) looseShade = 1.1 + nz * 0.00065; + else if (PH[m] === PH_SOLID && ( + (x > 0 && PH[mat[i - 1]] === PH_POWDER && !part[i - 1]) || + (x + 1 < W && PH[mat[i + 1]] === PH_POWDER && !part[i + 1]) || + (y > 0 && PH[mat[i - W]] === PH_POWDER && !part[i - W]) || + (y + 1 < H && PH[mat[i + W]] === PH_POWDER && !part[i + W]) + )) looseShade = 0.72; + } + let r, g, b; + if (lens) { + const ti = thermIdx(T); + if (m === EMPTY) { r = tl[ti] * 0.55; g = tl[ti + 1] * 0.55; b = tl[ti + 2] * 0.55; } + else { const v = (COLR[m] + COLG[m] + COLB[m]) / 3 * 0.25; r = tl[ti] * 0.78 + v; g = tl[ti + 1] * 0.78 + v; b = tl[ti + 2] * 0.78 + v; } + const p = part[i]; + r *= looseShade; g *= looseShade; b *= looseShade; + if (p && ((x + y) & 1)) { r = r * 0.7 + 40; g = g * 0.7 + 40; b = b * 0.7 + 40; } + if (fv < 255) { r *= fv / 255; g *= fv / 255; b *= fv / 255; } + px[row + bx] = 0xff000000 | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r); + return; + } + // background + let br, bgc, bb, sky = false; + const tb = bgm[i]; + if (tb && !sm) { const v = nz * CVAR[tb] / 128; br = TREE_R[tb] + v; bgc = TREE_G[tb] + v; bb = TREE_B[tb] + v; if (digp[i]) { const f = 1 + digp[i] / 700; br *= f; bgc *= f; bb *= f; } } + else if (y < surfY[x]) { br = skr; bgc = skg; bb = skb; sky = true; } + else { + const L = y < dirtY[x] ? BG.dirt : y < graniteY[x] ? BG.stone : y < basaltY[x] ? BG.granite : BG.basalt; + const v = sm ? 0 : nz * 0.06; + br = L[0] + v; bgc = L[1] + v; bb = L[2] + v; + } + if (sm) { + // the colour behind the grain (alpha marks open sky), and a tree in front of that if there is one + bpx[row + bx] = (sky ? 0xff000000 : 0) | ((bb & 255) << 16) | ((bgc & 255) << 8) | (br & 255); + if (tb) { + const v = nz * CVAR[tb] / 256, f = digp[i] ? 1 + digp[i] / 700 : 1; + const tr = Math.max(0, Math.min(255, (TREE_R[tb] + v) * f)), tg = Math.max(0, Math.min(255, (TREE_G[tb] + v) * f)), tbb = Math.max(0, Math.min(255, (TREE_B[tb] + v) * f)); + tpx[row + bx] = ((tb === LEAVES ? 0xff : 0x80) << 24) | (tbb << 16) | (tg << 8) | tr; + } else tpx[row + bx] = 0; + } + if (m === EMPTY) { r = br; g = bgc; b = bb; } + else { + const v = nz * CVAR[m] * (sm ? (PH[m] === PH_SOLID ? 0 : grain * 0.35) : grain) / 128; + r = COLR[m] + v; g = COLG[m] + v; b = COLB[m] + v; + r *= looseShade; g *= looseShade; b *= looseShade; + if (m === FIRE) { const f = (life[i] & 15) * 6; r = 255; g = 120 + f; b = 30 + f * 0.5; } + else if (m === WATER && !sm) { const s = ((x * 3 + y * 5 + (anim >> 2)) & 15) === 0 ? 14 : 0; r += s; g += s; b += s; } + else if (m === BELTL || m === BELTR) { + const dir = m === BELTR ? -1 : 1, on = netSat(i) > 0, ly = y & 3, lx = x & 3; + if (ly === 0 || ly === 3) { + // top run moves with the load, bottom run returns the other way + const st = ((x + (ly === 0 ? dir : -dir) * (on ? anim >> 1 : 0)) >> 1) & 1; + const k = ly === 0 ? 1 : 0.72; + r = (st ? 92 : 56) * k; g = (st ? 96 : 58) * k; b = (st ? 104 : 66) * k; + } else if (lx === 1 || lx === 2) { + const spin = on ? ((anim >> 2) + (dir < 0 ? lx : 3 - lx) + ly) & 1 : (lx + ly) & 1; + r = spin ? 132 : 104; g = spin ? 120 : 94; b = spin ? 108 : 84; // copper rollers + } else { r = 46; g = 48; b = 54; } + } else if (m === PIPE) { + pipeCellColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; + } else if (m === SHAFT) { + const lx = x & 3, ly = y & 3; + if (lx === 0 || lx === 3 || ly === 0 || ly === 3) { if (((x + y) & 3) === 0) { r -= 22; g -= 20; b -= 16; } } + else { r -= 34; g -= 30; b -= 24; } + } + else if (m === GATE) { if ((x & 1) === 0 && (y & 3) !== 0) { r -= 40; g -= 24; b -= 20; } } + else if (m === CUTST) { const lx = x & 3, ly = y & 3; if (ly === 1 || ly === 3 || lx === (ly < 2 ? 3 : 1)) { r -= 34; g -= 33; b -= 30; } else if (ly === 0 || ly === 2) { r += 10; g += 10; b += 9; } } + else if (m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) { + const lx = x & 3, ly = y & 3, frame = lx === 0 || lx === 3 || ly === 0 || ly === 3; + r = frame ? 96 : 38; g = frame ? 102 : 42; b = frame ? 112 : 50; + } + else if (m === WOOD) { if ((y & 3) === 2 || ((x + (y >> 2) * 3) & 7) === 0) { r -= 26; g -= 22; b -= 16; } } // planks + else if (m === LWOOD) { if (((x * 3 + y) & 3) === 0) { r -= 30; g -= 26; b -= 18; } } // stringy fibres + else if (m === HULL) { if (((x * 7 + y * 3) & 15) === 0) { r += 20; g += 20; b += 22; } } + else if (m === SPRING) { if ((nz & 7) === 0) { r = 90; g = 150; b = 220; } } + const al = ALPHA[m]; + if (al < 1) { r = br + (r - br) * al; g = bgc + (g - bgc) * al; b = bb + (b - bb) * al; } + const dp = digp[i]; + if (dp) { + // shaken rock pales with dust and shivers before it shatters (the cracks are drawn over it) + const k = dp / 255, lift = k * 30 + Math.sin(anim * 1.9 + x * 1.3 + y * 0.7) * 6 * k; + r += lift; g += lift; b += lift * 1.08; + } + // built wall blocks read as tiles: light top-left edge, dark bottom-right edge + if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) { + const lx = x & 3, ly = y & 3; + const wc = blockP[(y >> 2) * BW + (x >> 2)]; + const edge = isSlope(wc) && (wc === BP_SLOPE_BL || wc === BP_SLOPE_TR ? lx === ly : lx + ly === 3); + if (edge || lx === 0 || ly === 0) { r += 16; g += 16; b += 16; } + else if (lx === 3 || ly === 3) { r -= 18; g -= 18; b -= 18; } + } + } + // field parts drawn over contents + const sb = slopeBeltAt(i, x, y, sm); + let p = part[i]; + if (p === P_FILTER && (sb >= 0 || isSlopeBelt(blockP[slopeSealOwner(i)]))) p = 0; + const db = diagonalScrewBlock(x, y, sm); + let diagonal = false; + if (db >= 0) { + const v = blockV[db], joins = diagonalJoins[db]; + const sd = diagonalScrewShape(x + 0.5, y + 0.5, db, v, joins); + p = sm || sd <= 0 ? SCREW_PARTS[v] : 0; + diagonal = !!p; + if (sm) tpx[row + bx] = (joins << 24) | ((y - ((db / BW) | 0) * BS + 8) << 16) | ((x - (db % BW) * BS + 8) << 8); + } + if (p && sm && (IS_SCREW[p] || p === P_FILTER)) { if (m === EMPTY || PH[m] === PH_GAS) { r = 0; g = 0; b = 0; } else { r = Math.max(1, r); } } + else if (p) { + if (IS_SCREW[p]) { + const on = netSat(i) > 0; + const vert = p === P_SCREW_U || p === P_SCREW_D; + const axis = diagonal ? Math.floor(diagonalAlong) : vert ? y : x; + const across = diagonal ? diagonalAcross + 2 : vert ? x & 3 : y & 3; + const dir = diagonal && blockV[db] >= 4 ? (blockV[db] >= 6 ? 1 : -1) : (p === P_SCREW_U || p === P_SCREW_L) ? 1 : -1; + if (diagonal ? Math.abs(diagonalAcross) > 1.38 || diagonalCap > -0.55 : across === 0 || across === 3) { // copper casing on the two long sides; its load shows through + if (m === EMPTY) { r = 150; g = 96; b = 64; } else { r = r * 0.72 + 44; g = g * 0.72 + 26; b = b * 0.72 + 14; } + if (!diagonal && (axis & 3) === 0) { r -= 24; g -= 18; b -= 12; } + } else { + // screw flights: a diagonal stripe that walks along the tube when powered + const ch = (((axis + across + dir * (on ? anim >> 1 : 0)) & 3) === 0); + if (m === EMPTY) { r = ch ? 120 : 46; g = ch ? 112 : 46; b = ch ? 104 : 52; } + else { r = r * 0.75 + (ch ? 34 : 8); g = g * 0.75 + (ch ? 30 : 8); b = b * 0.75 + (ch ? 28 : 10); } + } + } else if (p === P_FILTER) { + const lx = x & 3, ly = y & 3, fb = (y >> 2) * BW + (x >> 2), dir = blockV[fb] & 128 ? -1 : 1; + const fc = filterCfg.get(fb), fms = fc ? fc.m : null, fn = fms ? fms.length : 0, inv = fc && fc.inv; + if (ly === 0) { // rollers on top, turning the way the load rides; in carry mode, striped with what rides + const st = ((x + dir * (anim >> 1)) >> 1) & 1; + if (m === EMPTY || st) { + if (inv && fn && !st) { const c = fms[((x + dir * (anim >> 1)) >> 2 & 0xffff) % fn]; r = COLR[c] * 0.8; g = COLG[c] * 0.8; b = COLB[c] * 0.8; } + else { r = st ? 92 : 56; g = st ? 96 : 58; b = st ? 104 : 66; } + } + } else if (ly === 3) { // the belt's return run carries on under it, so belts and filters read as one belt + const st = ((x - dir * (anim >> 1)) >> 1) & 1; + if (m === EMPTY || st) { r = (st ? 92 : 56) * 0.72; g = (st ? 96 : 58) * 0.72; b = (st ? 104 : 66) * 0.72; } + } else if ((lx === 0 && !beltLike(fb, 0)) || (lx === 3 && !beltLike(fb, 1))) { if (m === EMPTY || ((x + y) & 1)) { r = 64; g = 66; b = 74; } } // frame only where the line ends + else if (m === EMPTY) { // mesh holes, tinted with what falls through them + if ((x + y) & 1) { r = 40; g = 42; b = 48; } + else if (inv) { r = 58; g = 74; b = 72; } // carry mode: everything else drops + else if (fn) { const c = fms[(((x >> 1) + (y >> 1)) & 0xffff) % fn]; r = COLR[c] * 0.75; g = COLG[c] * 0.75; b = COLB[c] * 0.75; } + else { r = 24; g = 25; b = 28; } + } + } else if (p === P_GRATE) { + if ((x + y) & 1) { r = 150; g = 78; b = 58; } else if (m === EMPTY) { r *= 0.6; g *= 0.6; b *= 0.6; } + } else if (p === P_GOPEN) { + if ((x & 3) === 0) { r = r * 0.6 + 50; g = g * 0.6 + 30; b = b * 0.6 + 26; } + } else if (p === P_INTAKE) { + if (m === EMPTY) { r = 30; g = 32; b = 36; } + if ((x & 1) === 0) { r += 50; g += 54; b += 60; } + } else if (p === P_PIPE) { + if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; } + } + } + const slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR); + if (slopeArt && !sm) { + const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb); + r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255; + } + const slopeDetail = slopeDetailAt(i, x, y, sm); + if (slopeDetail) { + const wave = slopeDetail === 2 && ((x + beltGrade(blockP[slopeWallOwner]) * y - slopePhase[slopeWallOwner]) & 3) < 2; + // ribs: light rubber with a pale cap; the far wall behind them: muted, corrugated rubber + const cap = slopeDetail === 1 && slopeWallOwner >= 0 && !cleatMask[i - W + beltGrade(blockP[slopeWallOwner])]; + r = slopeDetail === 1 ? (cap ? 178 : 146) : wave ? 64 : 56; + g = slopeDetail === 1 ? (cap ? 182 : 150) : wave ? 68 : 60; + b = slopeDetail === 1 ? (cap ? 190 : 158) : wave ? 76 : 68; + } + // incandescence + if (T > 450 && m !== EMPTY && incand) { + const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi]; + r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w; + if (glowOn && fv >= 128 && w > 0.3 && ((bx | by) & 1) === 0) { + const gk = (by >> 1) * gw + (bx >> 1), s = (w - 0.3) * 1.4; + gpx[gk] = 0xff000000 | (Math.min(255, GLOW_B[gi] * s) << 16) | (Math.min(255, GLOW_G[gi] * s) << 8) | Math.min(255, GLOW_R[gi] * s); + } + } + if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0; + let top = 0xff000000; + if (sm) { + // what kind of thing this grain is drawn as: 0 open, 1 terrain, 2 loose grain, 3 liquid, + // 4 built piece (drawn crisp), 5 smoke and flame, 6 built wall block (drawn as a tile) + let cl = 0; + const pt = (p === P_PIPE && !pipeBodyCell(x, y)) || (p === P_FILTER && sb >= 0) ? 0 : p; // the open parts of a pipe block show what is in them + if (IS_SCREW[pt]) cl = SCREW_CLASS[pt]; + else if (p === P_FILTER && sb < 0) { + // filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode + const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0; + cl = blockV[fb] & 128 ? 14 : 13; + tpx[row + bx] = ((0x20 | (fc && fc.inv ? 0x10 : 0) | beltCaps[fb]) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0); + } + else if (m === BELTL || m === BELTR) { + cl = m === BELTL ? 7 : 8; + tpx[row + bx] = beltCaps[(y >> 2) * BW + (x >> 2)] << 24; + } + else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4; + else if (m !== EMPTY) { + const ph = PH[m]; + if (ph === PH_SOLID) cl = (!(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) ? 6 : 1; + else if (ph === PH_POWDER) cl = 2; + else if (ph === PH_LIQUID) cl = 3; + else cl = 5; + } + const wallCode = blockP[(y >> 2) * BW + (x >> 2)]; + if (isSlope(wallCode) && ((m === EMPTY && !slopeCell(wallCode, x & 3, y & 3)) || (PH[m] === PH_SOLID && !(flags[i] & F_NAT)))) { + cl = wallCode; + const wm = WALL_OF[blockV[(y >> 2) * BW + (x >> 2)]]; + r = COLR[wm]; g = COLG[wm]; b = COLB[wm]; + } + if (sb >= 0 && (slopeArt || PH[m] === PH_POWDER)) { + const code = blockP[sb], dir = isFilter(code) ? (blockV[sb] & 128 ? 1 : 0) : blockV[sb]; + const slopeClass = 23 + (code - BP_BELT_UP) * 2 + dir; + if (slopeArt) cl = slopeClass; + const f = isFilter(code) && filterCfg.get(sb), fm = f && f.m.length ? f.m[(Math.floor((x + beltGrade(code) * y) / 2) & 65535) % f.m.length] : 0; + tpx[row + bx] = ((slopeBeltJoins[sb] | 128) << 24) | slopeClass | + ((y - ((sb / BW) | 0) * BS + 8) << 16) | ((x - (sb % BW) * BS + 8) << 8); + if (slopeArt) { r = fm ? COLR[fm] : 0; g = fm ? COLG[fm] : 0; b = fm ? COLB[fm] : 0; } + } + if (slopeDetail && slopeWallOwner >= 0) { + // ribs and the far wall are both drawn by the wall shader, ribs from the same phase as the simulation + const wb = slopeWallOwner; + cl = beltGrade(blockP[wb]) < 0 ? 31 : 32; + tpx[row + bx] = ((128 | slopePhase[wb] | ((slopeBeltJoins[wb] & 12) << 1) | (blockV[wb] & 1 ? 32 : 0)) << 24) | cl | + ((y - ((wb / BW) | 0) * BS + 8) << 16) | ((x - wb % BW * BS + 8) << 8); + } + top = cl << 24; + fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !blockP[(y >> 2) * BW + (x >> 2)] && !mach[i] ? 0xff : 0); // terrain: any solid nothing owns, natural or not + } + if (fv < 255) { + const k = fv / 255; r *= k; g *= k; b *= k; + if (sm) { + const c = bpx[row + bx], t = tpx[row + bx]; + bpx[row + bx] = fogColor(c, k); tpx[row + bx] = fogColor(t, k); + } + } + px[row + bx] = top | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r); +} diff --git a/src/render/gpu/colour.js b/src/render/gpu/colour.js new file mode 100644 index 0000000..5c32dac --- /dev/null +++ b/src/render/gpu/colour.js @@ -0,0 +1,82 @@ +// The plain-cell rules mirror cellPixel; built geometry stays in the CPU override. +const GPU_COLOUR = ` +struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f } +@group(0) @binding(0) var u: Params; +@group(0) @binding(1) var lut: array; +@group(0) @binding(2) var land: array; +${['mat','flags','life','part','bgm','digp','noise','fog'].map((n,i)=>`@group(0) @binding(${i+3}) var ${n}T: texture_2d;`).join('\n')} +@group(0) @binding(11) var tempT: texture_2d; +@group(0) @binding(12) var overrideT: texture_2d; +@group(0) @binding(13) var overrideGlow: texture_2d; +@group(0) @binding(14) var outputT: texture_storage_2d; +@group(0) @binding(15) var glowT: texture_storage_2d; +fn phase(p: vec2i) -> i32 { return i32(lut[textureLoad(matT,p,0).r*3u+1u].x); } +fn powder(p: vec2i) -> bool { + return all(p >= vec2i(0)) && all(p < vec2i(u.world.xy)) && phase(p)==${PH_POWDER} && textureLoad(partT,p,0).r==0u; +} +fn quant(c: vec3f) -> vec3f { return floor(clamp(c,vec3f(0),vec3f(255)))/255.; } +@compute @workgroup_size(8,8) +fn colour(@builtin(global_invocation_id) id: vec3u) { + let q=vec2i(id.xy); if (any(q>=vec2i(u.view.zw))) { return; } + let p=q+vec2i(u.view.xy); let x=p.x; let y=p.y; + var rgb=vec3f(0); var bloom=vec3f(0); + if (all(p>=vec2i(0)) && all(p0.) { + let f=vec2f(p)/${FOG}.; let a=vec2i(f); let b=min(a+1,vec2i(u.world.zw)-1); let t=fract(f); + fv=mix(mix(f32(textureLoad(fogT,a,0).r),f32(textureLoad(fogT,vec2i(b.x,a.y),0).r),t.x),mix(f32(textureLoad(fogT,vec2i(a.x,b.y),0).r),f32(textureLoad(fogT,b,0).r),t.x),t.y); + if (fv>0. && fv<255.) { var s=fv/255.; s=clamp(s+nz*(0.6/128.)*s*(1.-s),0.,1.); fv=max(1.,255.*s*s*(3.-2.*s)); } + } + if (fv>0.) { + var shade=1.; + let built=props.x==${PH_SOLID}. && (textureLoad(flagsT,p,0).r & ${F_NAT}u)==0u; + if (m!=${EMPTY}u && !built) { + if (props.x==${PH_POWDER}.) { shade=1.1+nz*0.00065; } + else if (props.x==${PH_SOLID}. && (powder(p+vec2i(-1,0)) || powder(p+vec2i(1,0)) || powder(p+vec2i(0,-1)) || powder(p+vec2i(0,1)))) { shade=0.72; } + } + if (u.effects.x>0.) { + let ti=u32(clamp(i32(T/4.),0,550)); let thermal=lut[${NMAT*3+700}u+u32(u.effects.y)*551u+ti].rgb; + rgb=select(thermal*0.78+vec3f((mc.r+mc.g+mc.b)/3.*0.25),thermal*0.55,m==${EMPTY}u)*shade; + } else { + let tb=textureLoad(bgmT,p,0).r; let dp=f32(textureLoad(digpT,p,0).r); + let column=land[x]; let sky=land[i32(u.world.x)+y].rgb; + var back: vec3f; + if (tb!=0u) { back=(lut[tb*3u+2u].rgb+vec3f(nz*lut[tb*3u].a/128.))*(1.+dp/700.); } + else if (f32(y)0.01) { let gy=(sky.r+sky.g+sky.b)/3.*0.82; back+= (vec3f(gy)-sky)*u.style.y*vec3f(0.7,0.7,0.65); } + } else { + back=vec3f(${BG.basalt.join(',')}); + if (f32(y)>2)) & 15)==0) { rgb+=vec3f(14.); } + rgb=back+(rgb-back)*props.y; + if (dp>0.) { let lift=dp/255.*(30.+sin(u.style.w*1.9+f32(x)*1.3+f32(y)*0.7)*6.); rgb+=lift*vec3f(1,1,1.08); } + } + if (T>450. && m!=${EMPTY}u && u.effects.z>0.) { + var gt=T; + if (props.x==${PH_SOLID}.) { gt+=nz*0.9; } + else if (m==${MAGMA}u) { gt+=sin(f32(x)*0.19+f32(y)*0.11+u.style.w*0.03+nz*0.04)*55.-30.; } + let gi=u32(clamp(i32((gt-400.)/4.),0,699)); let glow=lut[${NMAT*3}u+gi]; + rgb=rgb*(1.-glow.a)+glow.rgb*glow.a; + if (u.effects.w>0. && fv>=128. && glow.a>0.3) { bloom=quant(glow.rgb*(glow.a-0.3)*1.4); } + } + } + rgb=quant(rgb*fv/255.); + } + } + // Flying grains replace colour, while the glow behind them remains. + let over=textureLoad(overrideT,q,0); + if (over.a>0.) { rgb=over.rgb; bloom=max(bloom,textureLoad(overrideGlow,q/2,0).rgb); } + textureStore(outputT,q,vec4f(rgb,1)); + if (((q.x | q.y) & 1)==0) { textureStore(glowT,q/2,vec4f(bloom,1)); } +}`; diff --git a/src/render/gpu/device.js b/src/render/gpu/device.js new file mode 100644 index 0000000..4439418 --- /dev/null +++ b/src/render/gpu/device.js @@ -0,0 +1,43 @@ +// Async startup shares the title screen's adapter; failures leave a usable legacy renderer. +const GPU = { ok: false, canvas: document.getElementById('gpu'), error: '', gpuMs: null }; +async function gpuInit(adapter) { + try { + const features = adapter.features.has('timestamp-query') ? ['timestamp-query'] : []; + const d = GPU.device = await adapter.requestDevice({ requiredFeatures: features }); + d.addEventListener('uncapturederror', e => gpuFail(e.error.message)); + d.lost.then(info => gpuFail(info.message || 'Graphics device lost')); + GPU.context = GPU.canvas.getContext('webgpu'); + GPU.format = navigator.gpu.getPreferredCanvasFormat(); + GPU.context.configure({ device: d, format: GPU.format, alphaMode: 'opaque' }); + d.pushErrorScope('validation'); + GPU.colour = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR }), entryPoint: 'colour' } }); + GPU.blur = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_BLUR }), entryPoint: 'main' } }); + GPU.draw = await d.createRenderPipelineAsync({ layout: 'auto', vertex: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'vs' }, fragment: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'fs', targets: [{ format: GPU.format }] }, primitive: { topology: 'triangle-list' } }); + const error = await d.popErrorScope(); if (error) throw error; + GPU.uniform = d.createBuffer({ size: 128, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + GPU.params = new Float32Array(32); + GPU.sampler = d.createSampler({ magFilter: 'linear', minFilter: 'linear' }); + if (features.length) { + GPU.queries = d.createQuerySet({ type: 'timestamp', count: 2 }); + GPU.resolve = d.createBuffer({ size: 256, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC }); + GPU.readback = d.createBuffer({ size: 16, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + } + GPU.ok = true; + } catch (e) { gpuFail(e.message); } +} +function gpuFail(message) { + GPU.ok = false; GPU.error = message; SET.renderer = 'legacy'; + GPU.canvas.hidden = true; + console.warn('WebGPU renderer: ' + message); + if (GAME.running) toast('WebGPU stopped drawing. Legacy renderer is on.'); +} +// Tests sample this composite so a transparent overlay cannot pass world-colour checks. +function worldPixelContext() { + if (!R.gpuOn) return R.ctx; + // Canvas textures expire after presentation; redraw the retained result before copying. + const encoder = GPU.device.createCommandEncoder(); gpuPresent(encoder); + GPU.device.queue.submit([encoder.finish()]); + const c = document.createElement('canvas'); c.width = R.canvas.width; c.height = R.canvas.height; + const ctx = c.getContext('2d'); ctx.drawImage(GPU.canvas, 0, 0); ctx.drawImage(R.canvas, 0, 0); + return ctx; +} diff --git a/src/render/gpu/draw.js b/src/render/gpu/draw.js new file mode 100644 index 0000000..b929c30 --- /dev/null +++ b/src/render/gpu/draw.js @@ -0,0 +1,69 @@ +// Only exceptional blocks and their geometric neighbours run the CPU colour rules. +const GPU_PATTERN = new Uint8Array(NMAT); +for (const m of [BELTL, BELTR, PIPE, SHAFT, GATE, CUTST, GRINDER, SAWB, PRESSB, RACKB, WOOD, LWOOD, HULL, SPRING]) GPU_PATTERN[m] = 1; +function gpuOverrides() { + R.px.fill(0); + if (!GPU.blocks || GPU.blocks.length !== NB) GPU.blocks = new Uint8Array(NB); + const marked = GPU.blocks; marked.fill(0); + const bx0 = Math.max(0, (R.vx0 >> 2) - 3), by0 = Math.max(0, (R.vy0 >> 2) - 3); + const bx1 = Math.min(BW - 1, ((R.vx0 + R.bw) >> 2) + 3), by1 = Math.min(BH - 1, ((R.vy0 + R.bh) >> 2) + 3); + for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) { + const b = by * BW + bx, code = blockP[b]; + let rare = !!code; + if (!rare) for (let dy = 0; dy < BS && !rare; dy++) for (let dx = 0; dx < BS; dx++) { + const i = (by * BS + dy) * W + bx * BS + dx; + if (part[i] || mach[i] || occ[i] || GPU_PATTERN[mat[i]]) { rare = true; break; } + } + if (!rare) continue; + marked[b] = 1; + if (code === BP_SCREW || isSlopeBelt(code)) { + // Covers joins, the far wall and cleats, including cells outside the owning block. + for (let yy = Math.max(0, by - 3); yy <= Math.min(BH - 1, by + 2); yy++) + for (let xx = Math.max(0, bx - 2); xx <= Math.min(BW - 1, bx + 2); xx++) marked[yy * BW + xx] = 1; + } + } + GPU.overrideCount = 0; + for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) { + if (!marked[by * BW + bx]) continue; + const x0 = Math.max(bx * BS, R.vx0), y0 = Math.max(by * BS, R.vy0); + const x1 = Math.min(bx * BS + BS, R.vx0 + R.bw, W), y1 = Math.min(by * BS + BS, R.vy0 + R.bh, H); + for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) cellPixel(x, y); + } +} +function gpuDraw(lighting, glowOn) { + const upload = performance.now(); + gpuUpload(lighting, glowOn); + if (GPU.canvas.width !== R.canvas.width || GPU.canvas.height !== R.canvas.height) { + GPU.canvas.width = R.canvas.width; GPU.canvas.height = R.canvas.height; + } + const blit = performance.now(), d = GPU.device, encoder = d.createCommandEncoder(); + const timed = GPU.queries && !GPU.timingBusy && (PERF_MARKS || SET.showPerf); + const compute = encoder.beginComputePass(timed ? { timestampWrites: { querySet: GPU.queries, beginningOfPassWriteIndex: 0 } } : {}); + compute.setPipeline(GPU.colour); compute.setBindGroup(0, GPU.bindColour); + compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8)); compute.end(); + if (glowOn) for (let i = 0; i < 2; i++) { + const pass = encoder.beginComputePass(); pass.setPipeline(GPU.blur); pass.setBindGroup(0, GPU.bindBlur[i]); + pass.dispatchWorkgroups(Math.ceil(R.glowC.width / 8), Math.ceil(R.glowC.height / 8)); pass.end(); + } + gpuPresent(encoder, timed); + if (timed) { encoder.resolveQuerySet(GPU.queries, 0, 2, GPU.resolve, 0); encoder.copyBufferToBuffer(GPU.resolve, 0, GPU.readback, 0, 16); } + d.queue.submit([encoder.finish()]); + if (timed) { + GPU.timingBusy = true; + GPU.timingPromise = GPU.readback.mapAsync(GPUMapMode.READ).then(() => { + const ns = new BigUint64Array(GPU.readback.getMappedRange()); GPU.gpuMs = Number(ns[1] - ns[0]) / 1e6; + GPU.readback.unmap(); GPU.timingBusy = false; + }).catch(() => { GPU.timingBusy = false; }); + } + if (PERF_MARKS || SET.showPerf) { + perfMarks.override = perfMarks.color; perfMarks.upload = blit - upload; perfMarks.blit = performance.now() - blit; + if (GPU.gpuMs !== null) perfMarks.gpu = GPU.gpuMs; + } +} + +// Also used for readback after the browser has expired the presented canvas texture. +function gpuPresent(encoder, timed = false) { + const pass = encoder.beginRenderPass({ colorAttachments: [{ view: GPU.context.getCurrentTexture().createView(), loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 1] }], + ...(timed ? { timestampWrites: { querySet: GPU.queries, endOfPassWriteIndex: 1 } } : {}) }); + pass.setPipeline(GPU.draw); pass.setBindGroup(0, GPU.bindDraw); pass.draw(3); pass.end(); +} diff --git a/src/render/gpu/shaders.js b/src/render/gpu/shaders.js new file mode 100644 index 0000000..1289446 --- /dev/null +++ b/src/render/gpu/shaders.js @@ -0,0 +1,42 @@ +// Half-resolution separable Gaussian bloom, then nearest colour + linear light. +const GPU_BLUR = ` +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var dest: texture_storage_2d; +@group(0) @binding(2) var blur: vec4f; +@compute @workgroup_size(8,8) +fn main(@builtin(global_invocation_id) id: vec3u) { + let p=vec2i(id.xy); let size=vec2i(textureDimensions(source)); if (any(p>=size)) { return; } + var sum=vec3f(0); var weight=0.; let sigma=blur.z; + let radius=i32(ceil(sigma*3.)); + for (var j=-radius;j<=radius;j++) { + let w=exp(-f32(j*j)/(2.*sigma*sigma)); let q=p+vec2i(blur.xy)*j; + if (all(q>=vec2i(0)) && all(q u: Params; +@group(0) @binding(1) var colour: texture_2d; +@group(0) @binding(2) var light: texture_2d; +@group(0) @binding(3) var glow: texture_2d; +@group(0) @binding(4) var linear: sampler; +@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + let pos=array(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3)); return vec4f(pos[i],0,1); +} +@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f { + let cell=(p.xy-u.camera.xy)/u.camera.z; + var c=textureLoad(colour,vec2i(floor(cell)),0).rgb; + if (u.light.w>0.) { + let world=p.xy/u.camera.z+u.extra.xy; + let uv=(world/u.light.z-u.light.xy)/vec2f(textureDimensions(light)); + let l=textureSampleLevel(light,linear,uv,0).rgb; + c=round(c*l*255.)/255.; + } + if (u.effects.w>0.) { + let uv=cell/(2.*vec2f(textureDimensions(glow))); + c+=textureSampleLevel(glow,linear,uv,0).rgb*u.screen.z; + } + return vec4f(c,1); +}`; diff --git a/src/render/gpu/textures.js b/src/render/gpu/textures.js new file mode 100644 index 0000000..0a62b2e --- /dev/null +++ b/src/render/gpu/textures.js @@ -0,0 +1,84 @@ +// Whole-world allocation, direct strided uploads; no cell packing or dirty tracking. +function gpuTexture(w, h, format = 'rgba8unorm', storage = false) { + return GPU.device.createTexture({ size: [w, h], format, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | (storage ? GPUTextureUsage.STORAGE_BINDING : 0) }); +} +function gpuWrite(texture, data, stride, x, y, w, h) { + if (w <= 0 || h <= 0) return; + const bytes = data.BYTES_PER_ELEMENT; + GPU.device.queue.writeTexture({ texture, origin: [x, y] }, data, { offset: (y * stride + x) * bytes, bytesPerRow: stride * bytes }, [w, h]); +} +function gpuBuffer(data) { + const b = GPU.device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); + GPU.device.queue.writeBuffer(b, 0, data); return b; +} +function gpuWorldTextures() { + if (GPU.world === mat && GPU.sky === SKY_R) return; + if (GPU.worldTextures) for (const t of GPU.worldTextures) t.destroy(); + if (GPU.lut) GPU.lut.destroy(); if (GPU.land) GPU.land.destroy(); + GPU.world = mat; GPU.sky = SKY_R; + GPU.worldTextures = Array.from({ length: 8 }, (_, i) => gpuTexture(i === 7 ? FW : W, i === 7 ? FH : H, 'r8uint')); + GPU.temp = gpuTexture(W, H, 'r32float'); GPU.worldTextures.push(GPU.temp); + gpuWrite(GPU.worldTextures[6], noiseB, W, 0, 0, W, H); + const tables = new Float32Array((NMAT * 3 + 700 + 551 * 3) * 4); + for (let m = 0; m < NMAT; m++) { + tables.set([COLR[m], COLG[m], COLB[m], CVAR[m], PH[m], ALPHA[m], 0, 0, TREE_R[m], TREE_G[m], TREE_B[m], 0], m * 12); + } + for (let i = 0; i < 700; i++) tables.set([GLOW_R[i], GLOW_G[i], GLOW_B[i], GLOW_W[i]], (NMAT * 3 + i) * 4); + for (const [j, palette] of Object.values(THERM).entries()) for (let i = 0; i < 551; i++) tables.set([palette[i * 3], palette[i * 3 + 1], palette[i * 3 + 2], 0], (NMAT * 3 + 700 + j * 551 + i) * 4); + GPU.lut = gpuBuffer(tables); + GPU.landData = new Float32Array((W + H) * 4); + GPU.land = gpuBuffer(GPU.landData); + GPU.bindColour = null; +} +function gpuViewTextures() { + const key = `${R.bw}/${R.bh}/${R.lw}/${R.lh}`; + if (GPU.viewKey === key) return; + GPU.viewKey = key; + if (GPU.viewTextures) for (const t of GPU.viewTextures) t.destroy(); + const bw = R.bw, bh = R.bh, gw = R.glowC.width, gh = R.glowC.height; + GPU.override = gpuTexture(bw, bh); GPU.overrideGlow = gpuTexture(gw, gh); + GPU.colourTex = gpuTexture(bw, bh, 'rgba8unorm', true); + GPU.glowTex = gpuTexture(gw, gh, 'rgba8unorm', true); + GPU.blurX = gpuTexture(gw, gh, 'rgba8unorm', true); GPU.blurY = gpuTexture(gw, gh, 'rgba8unorm', true); + GPU.lightTex = gpuTexture(Math.max(1, R.lw), Math.max(1, R.lh)); + GPU.viewTextures = [GPU.override, GPU.overrideGlow, GPU.colourTex, GPU.glowTex, GPU.blurX, GPU.blurY, GPU.lightTex]; + GPU.bindColour = null; +} +function gpuBindings() { + if (GPU.bindColour) return; + const d = GPU.device, buffer = b => ({ buffer: b }), view = t => t.createView(); + const group = (pipeline, resources) => d.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: resources.map((resource, binding) => ({ binding, resource })) }); + GPU.bindColour = group(GPU.colour, [buffer(GPU.uniform), buffer(GPU.lut), buffer(GPU.land), ...GPU.worldTextures.map(view), view(GPU.override), view(GPU.overrideGlow), view(GPU.colourTex), view(GPU.glowTex)]); + GPU.bindDraw = group(GPU.draw, [buffer(GPU.uniform), view(GPU.colourTex), view(GPU.lightTex), view(GPU.blurY), GPU.sampler]); + if (GPU.blurUniforms) for (const b of GPU.blurUniforms) b.destroy(); + GPU.blurUniforms = [0, 1].map(() => d.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST })); + GPU.bindBlur = [group(GPU.blur, [view(GPU.glowTex), view(GPU.blurX), buffer(GPU.blurUniforms[0])]), group(GPU.blur, [view(GPU.blurX), view(GPU.blurY), buffer(GPU.blurUniforms[1])])]; +} +function gpuUpload(lighting, glowOn) { + gpuWorldTextures(); gpuViewTextures(); gpuBindings(); + const x = Math.max(0, R.vx0 - 2), y = Math.max(0, R.vy0 - 2); + const w = Math.min(W, R.vx0 + R.bw + 2) - x, h = Math.min(H, R.vy0 + R.bh + 2) - y; + const arrays = [mat, flags, life, part, bgm, digp]; + arrays.forEach((a, i) => gpuWrite(GPU.worldTextures[i], a, W, x, y, w, h)); + gpuWrite(GPU.temp, temp, W, x, y, w, h); + const fx = Math.max(0, Math.floor(x / FOG)), fy = Math.max(0, Math.floor(y / FOG)); + gpuWrite(GPU.worldTextures[7], fog, FW, fx, fy, Math.min(FW, Math.ceil((x + w) / FOG) + 1) - fx, Math.min(FH, Math.ceil((y + h) / FOG) + 1) - fy); + // Small per-row/column tables may change in scene tools independently of world allocation. + const land = GPU.landData; + for (let i = 0; i < W; i++) { const k = i * 4; land[k] = surfY[i]; land[k + 1] = dirtY[i]; land[k + 2] = graniteY[i]; land[k + 3] = basaltY[i]; } + for (let i = 0; i < H; i++) { const k = (W + i) * 4; land[k] = SKY_R[i]; land[k + 1] = SKY_G[i]; land[k + 2] = SKY_B[i]; land[k + 3] = AMB[i]; } + const q = GPU.device.queue; + q.writeBuffer(GPU.land, 0, land); + gpuWrite(GPU.override, R.px, R.bw, 0, 0, R.bw, R.bh); + gpuWrite(GPU.overrideGlow, R.glowPx, R.glowC.width, 0, 0, R.glowC.width, R.glowC.height); + if (lighting) gpuWrite(GPU.lightTex, R.lightPx, R.lw, 0, 0, R.lw, R.lh); + const zp = R.zp, ox = Math.round(-(R.camX - R.vx0) * zp), oy = Math.round(-(R.camY - R.vy0) * zp); + GPU.params.set([R.vx0, R.vy0, R.bw, R.bh, W, H, FW, FH, + +SET.fog, R.rain, SET.grain, R.frame, +R.lens, Math.max(0, Object.keys(THERM).indexOf(SET.thermalPalette)), +SET.incandescence, +glowOn, + ox, oy, zp, 0, R.lx0, R.ly0, R.lres, +lighting, + R.canvas.width, R.canvas.height, SET.glow === 'high' ? 0.9 : 0.5, 0, R.camX, R.camY, 0, 0]); + q.writeBuffer(GPU.uniform, 0, GPU.params); + const sigma = Math.max(2, zp * (SET.glow === 'high' ? 3.2 : 2.2)) / (2 * zp); + q.writeBuffer(GPU.blurUniforms[0], 0, new Float32Array([1, 0, sigma, 0])); + q.writeBuffer(GPU.blurUniforms[1], 0, new Float32Array([0, 1, sigma, 0])); +} diff --git a/src/render/light.js b/src/render/light.js index 9221eb9..8126246 100644 --- a/src/render/light.js +++ b/src/render/light.js @@ -99,5 +99,5 @@ function computeLight() { r = r >= 1 ? 255 : (r * 255) | 0; g = g >= 1 ? 255 : (g * 255) | 0; b = b >= 1 ? 255 : (b * 255) | 0; px[k] = 0xff000000 | (b << 16) | (g << 8) | r; } - if (!R.smoothOn) R.lightX.putImageData(R.lightImg, 0, 0); + if (!R.smoothOn && !R.gpuOn) R.lightX.putImageData(R.lightImg, 0, 0); } diff --git a/src/render/objects.js b/src/render/objects.js new file mode 100644 index 0000000..7fa658e --- /dev/null +++ b/src/render/objects.js @@ -0,0 +1,80 @@ +// Loose bars, flying grains and queued blueprint blocks in the grain buffer. +function worldObjects() { + const { bw, bh, px, vx0, vy0, smoothOn: sm, lens } = R; + const useFog = SET.fog, incand = SET.incandescence; + // bars and other loose objects + if (!lens) for (const e of ents) { + if (useFog && fogSeen(e.ix, e.iy) < 64) continue; + const ex = e.ix - vx0, ey = e.iy - vy0; + if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue; + const m = e.m; + for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) { + const sx = ex + dx, sy = ey + dy; + if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue; + let r = COLR[m], g = COLG[m], b = COLB[m]; + const end = dx === 0 || dx === BAR_W - 1; + if (dy === 0) { r += end ? 10 : 34; g += end ? 8 : 28; b += end ? 6 : 24; } else { r -= end ? 34 : 18; g -= end ? 30 : 16; b -= end ? 26 : 14; } + if (e.T > 450 && incand) { const gi = glowIndex(e.T), w = GLOW_W[gi]; r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w; } + if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0; + if (R.gpuOn && !px[sy * bw + sx]) GPU.overrideCount++; + px[sy * bw + sx] = (sm ? 0x04000000 : 0xff000000) | ((b > 255 ? 255 : b | 0) << 16) | ((g > 255 ? 255 : g | 0) << 8) | (r > 255 ? 255 : r | 0); + } + } + // grains in flight from the vacuum nozzle, a screw or a belt (rain and snow are drawn over the top, see drawRain) + if (!lens) for (const f of flyers) { + if (f.rain || (useFog && fogSeen(f.x, f.y) < 64)) continue; + const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0; + if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue; + const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8; + if (R.gpuOn && !px[sy * bw + sx]) GPU.overrideCount++; + px[sy * bw + sx] = (sm ? 0x02000000 : 0xff000000) | (Math.max(0, Math.min(255, COLB[m] + v)) << 16) | (Math.max(0, Math.min(255, COLG[m] + v)) << 8) | Math.max(0, Math.min(255, COLR[m] + v)); + } + // blueprint ghosts: blocks waiting to be built + if (ghostBlocks.size) { + const bx0 = vx0 >> 2, by0 = vy0 >> 2, bx1 = (vx0 + bw) >> 2, by1 = (vy0 + bh) >> 2; + const pulse = (R.frame >> 3) & 3; + for (const [b, gv] of ghostBlocks) { + const gbx = b % BW, gby = (b / BW) | 0; + if (gbx < bx0 || gbx > bx1 || gby < by0 || gby > by1) continue; + const blocked = ghostBlocked.has(b), code = gv[0], v = gv[1]; + let tr = 90, tg = 190, tb = 230; + if (blocked) { tr = 230; tg = 70; tb = 60; } + else if (isWall(code) && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; } + const slope = isSlopeBelt(code), diagonal = code === BP_SCREW && (v >= 4 || straightScrewJoins(gbx, gby, v, ghostBlocks)); + const joins = slope ? slopeBeltConnections(gbx, gby, code, ghostBlocks) : diagonal ? (v >= 4 ? diagonalScrewJoins(gbx, gby, v, ghostBlocks) : straightScrewJoins(gbx, gby, v, ghostBlocks)) : 0; + const caps = code === BP_BELT || code === BP_FILTER ? flatBeltCaps(b, ghostBlocks) : 3; + for (let dy = slope ? -9 : diagonal ? -4 : 0; dy < (slope || diagonal ? 8 : BS); dy++) for (let dx = slope ? -5 : diagonal ? -4 : 0; dx < (slope ? 9 : diagonal ? 8 : BS); dx++) { + let sd = 0; + if (diagonal) { + if (v < 4 && ((v & 1) ? dx < 0 || dx >= BS : dy < 0 || dy >= BS)) continue; + const x = gbx * BS + dx, y = gby * BS + dy; + if (x < 0 || y < 0 || x >= W || y >= H) continue; + const i = y * W + x, n = (y >> 2) * BW + (x >> 2); + if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue; + sd = diagonalScrewShape(x + 0.5, y + 0.5, b, v, joins); + if (sd > 0) continue; + } + if (slope) { + const x = gbx * BS + dx, y = gby * BS + dy; + if (x < 0 || y < 0 || x >= W || y >= H) continue; + const i = y * W + x, n = (y >> 2) * BW + (x >> 2); + if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue; + sd = slopeBeltShape(x + 0.5, y + 0.5, b, joins, code); + const up = !isFilter(code) && slopeUphill(code, v), rib = up && slopePreviewCleat(x, y, b, code); + if (sd > 0 && !rib && (!up || slopeWallShape(x + 0.5, y + 0.5, b, code) > 0)) continue; + if (rib) sd = -0.1; + } + if (isSlope(code) && !slopeCell(code, dx, dy)) continue; + const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0; + if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue; + const k = sy * bw + sx; + if (R.gpuOn && !px[k]) cellPixel(sx + vx0, sy + vy0); + const c = px[k]; + const edge = slope || diagonal ? sd > -0.8 : (dx === 0 && (caps & 1)) || dy === 0 || (dx === BS - 1 && (caps & 2)) || dy === BS - 1; + const a = edge ? (((dx + dy + pulse) & 3) === 0 ? 0.85 : 0.6) : 0.28; + const pr = c & 255, pg = (c >> 8) & 255, pb = (c >> 16) & 255; + px[k] = (sm ? 0x04000000 : 0xff000000) | ((pb + (tb - pb) * a) << 16) | ((pg + (tg - pg) * a) << 8) | (pr + (tr - pr) * a); + } + } + } +} diff --git a/src/render/setup.js b/src/render/setup.js index 8c6989a..ae2ee13 100644 --- a/src/render/setup.js +++ b/src/render/setup.js @@ -138,7 +138,7 @@ function buildSky() { function initRender(canvas) { R.canvas = canvas; - R.ctx = canvas.getContext('2d', { alpha: false }); + R.ctx = canvas.getContext('2d'); R.buf = document.createElement('canvas'); R.bctx = R.buf.getContext('2d'); R.glowC = document.createElement('canvas'); diff --git a/src/render/world.js b/src/render/world.js index 8a242b8..766fbfa 100644 --- a/src/render/world.js +++ b/src/render/world.js @@ -3,18 +3,20 @@ function fogColor(c, k) { return (c & 0xff000000) | (((c >>> 16 & 255) * k) << 1 // drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look. // ---------- World pixels ---------- function drawWorld() { - const perfStart = PERF_MARKS ? performance.now() : 0; - if (PERF_MARKS) perfMarks.light = 0; - const bw = R.bw, bh = R.bh, px = R.px; + const perfStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0; + if (PERF_MARKS || SET.showPerf) { perfMarks.light = 0; perfMarks.override = 0; delete perfMarks.gpu; } + R.gpuOn = SET.renderer === 'webgpu' && SET.look === 'pixel' && GPU.ok; + GPU.canvas.hidden = !R.gpuOn; + if (R.gpuOn) R.ctx.clearRect(0, 0, R.canvas.width, R.canvas.height); + const bw = R.bw, bh = R.bh; R.vx0 = Math.floor(R.camX); R.vy0 = Math.floor(R.camY); const vx0 = R.vx0, vy0 = R.vy0; // how hard it is raining around the camera, eased so the sky greys over gently R.rain = (R.rain || 0) * 0.98 + rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + bw / 2)))) * 0.02; - const rk = R.rain; // Smooth look: the colours below are still worked out per grain, but each grain also gets a // class (alpha byte) and the GPU draws rounded shapes from them (see smooth-shaders.js) const sm = R.smoothOn = SET.look === 'smooth' && !R.lens && typeof smoothAvailable === 'function' && smoothAvailable(); - const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask; + const mmask = R.moldMask; if (sm) { mmask.fill(0); for (const mo of molds) { @@ -30,361 +32,23 @@ function drawWorld() { } } prepareSlopeBelts(vx0, vy0, vx0 + bw, vy0 + bh); - const useFog = SET.fog; - const OUT = sm ? 0x04000000 : 0xff000000; // unexplored and off the map: black const lighting = SET.lighting !== 'off' && !R.lens; if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) { const lightStart = PERF_MARKS ? performance.now() : 0; computeLight(); if (PERF_MARKS) perfMarks.light = performance.now() - lightStart; } - const colorStart = PERF_MARKS ? performance.now() : 0; - const grain = SET.grain, incand = SET.incandescence; - const anim = R.frame; - const lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno; - const glowOn = SET.glow !== 'off' && !lens; - const gpx = R.glowPx, gw = R.glowC.width; - if (glowOn) gpx.fill(0); - for (let by = 0; by < bh; by++) { - const y = vy0 + by; - const row = by * bw; - if (y < 0 || y >= H) { for (let bx = 0; bx < bw; bx++) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } } continue; } - const fy = y / FOG, fj = Math.floor(fy), ft = fy - fj, fr = fj * FW, fn = Math.min(FH - 1, fj + 1) * FW; - const yw = y * W; - let skr = SKY_R[y], skg = SKY_G[y], skb = SKY_B[y]; - if (rk > 0.01) { const gy = (skr + skg + skb) / 3 * 0.82; skr += (gy - skr) * rk * 0.7; skg += (gy - skg) * rk * 0.7; skb += (gy - skb) * rk * 0.65; } - for (let bx = 0; bx < bw; bx++) { - const x = vx0 + bx; - if (x < 0 || x >= W) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; } - let fv = 255; - if (useFog) { - const fx = x / FOG, j = Math.floor(fx), t = fx - j, j1 = Math.min(FW - 1, j + 1); - const a = fog[fr + j], b = fog[fr + j1], c = fog[fn + j], d = fog[fn + j1]; - if ((a & b & c & d) !== 255) fv = (a + (b - a) * t) * (1 - ft) + (c + (d - c) * t) * ft; - if (!fv) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; } - // ease the fade into black (smoothstep) and break up the half-resolution steps with grain - // noise that is strongest mid-fade and nothing at either end, so the outer edge has no rim - if (fv < 255) { - let s = fv / 255; - s = Math.min(1, Math.max(0, s + (noiseB[yw + x] - 128) * (0.6 / 128) * s * (1 - s))); - fv = Math.max(1, 255 * s * s * (3 - 2 * s)); - } - } - const i = yw + x, m = mat[i], nz = noiseB[i] - 128; - const T = temp[i]; - // Also used by the thermal lens, which always draws in pixels. - let looseShade = 1; - if (!sm && m !== EMPTY && !builtForLight(i, x, y)) { - if (PH[m] === PH_POWDER) looseShade = 1.1 + nz * 0.00065; - else if (PH[m] === PH_SOLID && ( - (x > 0 && PH[mat[i - 1]] === PH_POWDER && !part[i - 1]) || - (x + 1 < W && PH[mat[i + 1]] === PH_POWDER && !part[i + 1]) || - (y > 0 && PH[mat[i - W]] === PH_POWDER && !part[i - W]) || - (y + 1 < H && PH[mat[i + W]] === PH_POWDER && !part[i + W]) - )) looseShade = 0.72; - } - let r, g, b; - if (lens) { - const ti = thermIdx(T); - if (m === EMPTY) { r = tl[ti] * 0.55; g = tl[ti + 1] * 0.55; b = tl[ti + 2] * 0.55; } - else { const v = (COLR[m] + COLG[m] + COLB[m]) / 3 * 0.25; r = tl[ti] * 0.78 + v; g = tl[ti + 1] * 0.78 + v; b = tl[ti + 2] * 0.78 + v; } - const p = part[i]; - r *= looseShade; g *= looseShade; b *= looseShade; - if (p && ((x + y) & 1)) { r = r * 0.7 + 40; g = g * 0.7 + 40; b = b * 0.7 + 40; } - if (fv < 255) { r *= fv / 255; g *= fv / 255; b *= fv / 255; } - px[row + bx] = 0xff000000 | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r); - continue; - } - // background - let br, bgc, bb, sky = false; - const tb = bgm[i]; - if (tb && !sm) { const v = nz * CVAR[tb] / 128; br = TREE_R[tb] + v; bgc = TREE_G[tb] + v; bb = TREE_B[tb] + v; if (digp[i]) { const f = 1 + digp[i] / 700; br *= f; bgc *= f; bb *= f; } } - else if (y < surfY[x]) { br = skr; bgc = skg; bb = skb; sky = true; } - else { - const L = y < dirtY[x] ? BG.dirt : y < graniteY[x] ? BG.stone : y < basaltY[x] ? BG.granite : BG.basalt; - const v = sm ? 0 : nz * 0.06; - br = L[0] + v; bgc = L[1] + v; bb = L[2] + v; - } - if (sm) { - // the colour behind the grain (alpha marks open sky), and a tree in front of that if there is one - bpx[row + bx] = (sky ? 0xff000000 : 0) | ((bb & 255) << 16) | ((bgc & 255) << 8) | (br & 255); - if (tb) { - const v = nz * CVAR[tb] / 256, f = digp[i] ? 1 + digp[i] / 700 : 1; - const tr = Math.max(0, Math.min(255, (TREE_R[tb] + v) * f)), tg = Math.max(0, Math.min(255, (TREE_G[tb] + v) * f)), tbb = Math.max(0, Math.min(255, (TREE_B[tb] + v) * f)); - tpx[row + bx] = ((tb === LEAVES ? 0xff : 0x80) << 24) | (tbb << 16) | (tg << 8) | tr; - } else tpx[row + bx] = 0; - } - if (m === EMPTY) { r = br; g = bgc; b = bb; } - else { - const v = nz * CVAR[m] * (sm ? (PH[m] === PH_SOLID ? 0 : grain * 0.35) : grain) / 128; - r = COLR[m] + v; g = COLG[m] + v; b = COLB[m] + v; - r *= looseShade; g *= looseShade; b *= looseShade; - if (m === FIRE) { const f = (life[i] & 15) * 6; r = 255; g = 120 + f; b = 30 + f * 0.5; } - else if (m === WATER && !sm) { const s = ((x * 3 + y * 5 + (anim >> 2)) & 15) === 0 ? 14 : 0; r += s; g += s; b += s; } - else if (m === BELTL || m === BELTR) { - const dir = m === BELTR ? -1 : 1, on = netSat(i) > 0, ly = y & 3, lx = x & 3; - if (ly === 0 || ly === 3) { - // top run moves with the load, bottom run returns the other way - const st = ((x + (ly === 0 ? dir : -dir) * (on ? anim >> 1 : 0)) >> 1) & 1; - const k = ly === 0 ? 1 : 0.72; - r = (st ? 92 : 56) * k; g = (st ? 96 : 58) * k; b = (st ? 104 : 66) * k; - } else if (lx === 1 || lx === 2) { - const spin = on ? ((anim >> 2) + (dir < 0 ? lx : 3 - lx) + ly) & 1 : (lx + ly) & 1; - r = spin ? 132 : 104; g = spin ? 120 : 94; b = spin ? 108 : 84; // copper rollers - } else { r = 46; g = 48; b = 54; } - } else if (m === PIPE) { - pipeCellColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; - } else if (m === SHAFT) { - const lx = x & 3, ly = y & 3; - if (lx === 0 || lx === 3 || ly === 0 || ly === 3) { if (((x + y) & 3) === 0) { r -= 22; g -= 20; b -= 16; } } - else { r -= 34; g -= 30; b -= 24; } - } - else if (m === GATE) { if ((x & 1) === 0 && (y & 3) !== 0) { r -= 40; g -= 24; b -= 20; } } - else if (m === CUTST) { const lx = x & 3, ly = y & 3; if (ly === 1 || ly === 3 || lx === (ly < 2 ? 3 : 1)) { r -= 34; g -= 33; b -= 30; } else if (ly === 0 || ly === 2) { r += 10; g += 10; b += 9; } } - else if (m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) { - const lx = x & 3, ly = y & 3, frame = lx === 0 || lx === 3 || ly === 0 || ly === 3; - r = frame ? 96 : 38; g = frame ? 102 : 42; b = frame ? 112 : 50; - } - else if (m === WOOD) { if ((y & 3) === 2 || ((x + (y >> 2) * 3) & 7) === 0) { r -= 26; g -= 22; b -= 16; } } // planks - else if (m === LWOOD) { if (((x * 3 + y) & 3) === 0) { r -= 30; g -= 26; b -= 18; } } // stringy fibres - else if (m === HULL) { if (((x * 7 + y * 3) & 15) === 0) { r += 20; g += 20; b += 22; } } - else if (m === SPRING) { if ((nz & 7) === 0) { r = 90; g = 150; b = 220; } } - const al = ALPHA[m]; - if (al < 1) { r = br + (r - br) * al; g = bgc + (g - bgc) * al; b = bb + (b - bb) * al; } - const dp = digp[i]; - if (dp) { - // shaken rock pales with dust and shivers before it shatters (the cracks are drawn over it) - const k = dp / 255, lift = k * 30 + Math.sin(anim * 1.9 + x * 1.3 + y * 0.7) * 6 * k; - r += lift; g += lift; b += lift * 1.08; - } - // built wall blocks read as tiles: light top-left edge, dark bottom-right edge - if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) { - const lx = x & 3, ly = y & 3; - const wc = blockP[(y >> 2) * BW + (x >> 2)]; - const edge = isSlope(wc) && (wc === BP_SLOPE_BL || wc === BP_SLOPE_TR ? lx === ly : lx + ly === 3); - if (edge || lx === 0 || ly === 0) { r += 16; g += 16; b += 16; } - else if (lx === 3 || ly === 3) { r -= 18; g -= 18; b -= 18; } - } - } - // field parts drawn over contents - const sb = slopeBeltAt(i, x, y, sm); - let p = part[i]; - if (p === P_FILTER && (sb >= 0 || isSlopeBelt(blockP[slopeSealOwner(i)]))) p = 0; - const db = diagonalScrewBlock(x, y, sm); - let diagonal = false; - if (db >= 0) { - const v = blockV[db], joins = diagonalJoins[db]; - const sd = diagonalScrewShape(x + 0.5, y + 0.5, db, v, joins); - p = sm || sd <= 0 ? SCREW_PARTS[v] : 0; - diagonal = !!p; - if (sm) tpx[row + bx] = (joins << 24) | ((y - ((db / BW) | 0) * BS + 8) << 16) | ((x - (db % BW) * BS + 8) << 8); - } - if (p && sm && (IS_SCREW[p] || p === P_FILTER)) { if (m === EMPTY || PH[m] === PH_GAS) { r = 0; g = 0; b = 0; } else { r = Math.max(1, r); } } - else if (p) { - if (IS_SCREW[p]) { - const on = netSat(i) > 0; - const vert = p === P_SCREW_U || p === P_SCREW_D; - const axis = diagonal ? Math.floor(diagonalAlong) : vert ? y : x; - const across = diagonal ? diagonalAcross + 2 : vert ? x & 3 : y & 3; - const dir = diagonal && blockV[db] >= 4 ? (blockV[db] >= 6 ? 1 : -1) : (p === P_SCREW_U || p === P_SCREW_L) ? 1 : -1; - if (diagonal ? Math.abs(diagonalAcross) > 1.38 || diagonalCap > -0.55 : across === 0 || across === 3) { // copper casing on the two long sides; its load shows through - if (m === EMPTY) { r = 150; g = 96; b = 64; } else { r = r * 0.72 + 44; g = g * 0.72 + 26; b = b * 0.72 + 14; } - if (!diagonal && (axis & 3) === 0) { r -= 24; g -= 18; b -= 12; } - } else { - // screw flights: a diagonal stripe that walks along the tube when powered - const ch = (((axis + across + dir * (on ? anim >> 1 : 0)) & 3) === 0); - if (m === EMPTY) { r = ch ? 120 : 46; g = ch ? 112 : 46; b = ch ? 104 : 52; } - else { r = r * 0.75 + (ch ? 34 : 8); g = g * 0.75 + (ch ? 30 : 8); b = b * 0.75 + (ch ? 28 : 10); } - } - } else if (p === P_FILTER) { - const lx = x & 3, ly = y & 3, fb = (y >> 2) * BW + (x >> 2), dir = blockV[fb] & 128 ? -1 : 1; - const fc = filterCfg.get(fb), fms = fc ? fc.m : null, fn = fms ? fms.length : 0, inv = fc && fc.inv; - if (ly === 0) { // rollers on top, turning the way the load rides; in carry mode, striped with what rides - const st = ((x + dir * (anim >> 1)) >> 1) & 1; - if (m === EMPTY || st) { - if (inv && fn && !st) { const c = fms[((x + dir * (anim >> 1)) >> 2 & 0xffff) % fn]; r = COLR[c] * 0.8; g = COLG[c] * 0.8; b = COLB[c] * 0.8; } - else { r = st ? 92 : 56; g = st ? 96 : 58; b = st ? 104 : 66; } - } - } else if (ly === 3) { // the belt's return run carries on under it, so belts and filters read as one belt - const st = ((x - dir * (anim >> 1)) >> 1) & 1; - if (m === EMPTY || st) { r = (st ? 92 : 56) * 0.72; g = (st ? 96 : 58) * 0.72; b = (st ? 104 : 66) * 0.72; } - } else if ((lx === 0 && !beltLike(fb, 0)) || (lx === 3 && !beltLike(fb, 1))) { if (m === EMPTY || ((x + y) & 1)) { r = 64; g = 66; b = 74; } } // frame only where the line ends - else if (m === EMPTY) { // mesh holes, tinted with what falls through them - if ((x + y) & 1) { r = 40; g = 42; b = 48; } - else if (inv) { r = 58; g = 74; b = 72; } // carry mode: everything else drops - else if (fn) { const c = fms[(((x >> 1) + (y >> 1)) & 0xffff) % fn]; r = COLR[c] * 0.75; g = COLG[c] * 0.75; b = COLB[c] * 0.75; } - else { r = 24; g = 25; b = 28; } - } - } else if (p === P_GRATE) { - if ((x + y) & 1) { r = 150; g = 78; b = 58; } else if (m === EMPTY) { r *= 0.6; g *= 0.6; b *= 0.6; } - } else if (p === P_GOPEN) { - if ((x & 3) === 0) { r = r * 0.6 + 50; g = g * 0.6 + 30; b = b * 0.6 + 26; } - } else if (p === P_INTAKE) { - if (m === EMPTY) { r = 30; g = 32; b = 36; } - if ((x & 1) === 0) { r += 50; g += 54; b += 60; } - } else if (p === P_PIPE) { - if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; } - } - } - const slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR); - if (slopeArt && !sm) { - const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb); - r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255; - } - const slopeDetail = slopeDetailAt(i, x, y, sm); - if (slopeDetail) { - const wave = slopeDetail === 2 && ((x + beltGrade(blockP[slopeWallOwner]) * y - slopePhase[slopeWallOwner]) & 3) < 2; - // ribs: light rubber with a pale cap; the far wall behind them: muted, corrugated rubber - const cap = slopeDetail === 1 && slopeWallOwner >= 0 && !cleatMask[i - W + beltGrade(blockP[slopeWallOwner])]; - r = slopeDetail === 1 ? (cap ? 178 : 146) : wave ? 64 : 56; - g = slopeDetail === 1 ? (cap ? 182 : 150) : wave ? 68 : 60; - b = slopeDetail === 1 ? (cap ? 190 : 158) : wave ? 76 : 68; - } - // incandescence - if (T > 450 && m !== EMPTY && incand) { - const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi]; - r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w; - if (glowOn && fv >= 128 && w > 0.3 && ((bx | by) & 1) === 0) { - const gk = (by >> 1) * gw + (bx >> 1), s = (w - 0.3) * 1.4; - gpx[gk] = 0xff000000 | (Math.min(255, GLOW_B[gi] * s) << 16) | (Math.min(255, GLOW_G[gi] * s) << 8) | Math.min(255, GLOW_R[gi] * s); - } - } - if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0; - let top = 0xff000000; - if (sm) { - // what kind of thing this grain is drawn as: 0 open, 1 terrain, 2 loose grain, 3 liquid, - // 4 built piece (drawn crisp), 5 smoke and flame, 6 built wall block (drawn as a tile) - let cl = 0; - const pt = (p === P_PIPE && !pipeBodyCell(x, y)) || (p === P_FILTER && sb >= 0) ? 0 : p; // the open parts of a pipe block show what is in them - if (IS_SCREW[pt]) cl = SCREW_CLASS[pt]; - else if (p === P_FILTER && sb < 0) { - // filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode - const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0; - cl = blockV[fb] & 128 ? 14 : 13; - tpx[row + bx] = ((0x20 | (fc && fc.inv ? 0x10 : 0) | beltCaps[fb]) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0); - } - else if (m === BELTL || m === BELTR) { - cl = m === BELTL ? 7 : 8; - tpx[row + bx] = beltCaps[(y >> 2) * BW + (x >> 2)] << 24; - } - else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4; - else if (m !== EMPTY) { - const ph = PH[m]; - if (ph === PH_SOLID) cl = (!(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) ? 6 : 1; - else if (ph === PH_POWDER) cl = 2; - else if (ph === PH_LIQUID) cl = 3; - else cl = 5; - } - const wallCode = blockP[(y >> 2) * BW + (x >> 2)]; - if (isSlope(wallCode) && ((m === EMPTY && !slopeCell(wallCode, x & 3, y & 3)) || (PH[m] === PH_SOLID && !(flags[i] & F_NAT)))) { - cl = wallCode; - const wm = WALL_OF[blockV[(y >> 2) * BW + (x >> 2)]]; - r = COLR[wm]; g = COLG[wm]; b = COLB[wm]; - } - if (sb >= 0 && (slopeArt || PH[m] === PH_POWDER)) { - const code = blockP[sb], dir = isFilter(code) ? (blockV[sb] & 128 ? 1 : 0) : blockV[sb]; - const slopeClass = 23 + (code - BP_BELT_UP) * 2 + dir; - if (slopeArt) cl = slopeClass; - const f = isFilter(code) && filterCfg.get(sb), fm = f && f.m.length ? f.m[(Math.floor((x + beltGrade(code) * y) / 2) & 65535) % f.m.length] : 0; - tpx[row + bx] = ((slopeBeltJoins[sb] | 128) << 24) | slopeClass | - ((y - ((sb / BW) | 0) * BS + 8) << 16) | ((x - (sb % BW) * BS + 8) << 8); - if (slopeArt) { r = fm ? COLR[fm] : 0; g = fm ? COLG[fm] : 0; b = fm ? COLB[fm] : 0; } - } - if (slopeDetail && slopeWallOwner >= 0) { - // ribs and the far wall are both drawn by the wall shader, ribs from the same phase as the simulation - const wb = slopeWallOwner; - cl = beltGrade(blockP[wb]) < 0 ? 31 : 32; - tpx[row + bx] = ((128 | slopePhase[wb] | ((slopeBeltJoins[wb] & 12) << 1) | (blockV[wb] & 1 ? 32 : 0)) << 24) | cl | - ((y - ((wb / BW) | 0) * BS + 8) << 16) | ((x - wb % BW * BS + 8) << 8); - } - top = cl << 24; - fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !blockP[(y >> 2) * BW + (x >> 2)] && !mach[i] ? 0xff : 0); // terrain: any solid nothing owns, natural or not - } - if (fv < 255) { - const k = fv / 255; r *= k; g *= k; b *= k; - if (sm) { - const c = bpx[row + bx], t = tpx[row + bx]; - bpx[row + bx] = fogColor(c, k); tpx[row + bx] = fogColor(t, k); - } - } - px[row + bx] = top | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r); - } - } - // bars and other loose objects - if (!lens) for (const e of ents) { - if (useFog && fogSeen(e.ix, e.iy) < 64) continue; - const ex = e.ix - vx0, ey = e.iy - vy0; - if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue; - const m = e.m; - for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) { - const sx = ex + dx, sy = ey + dy; - if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue; - let r = COLR[m], g = COLG[m], b = COLB[m]; - const end = dx === 0 || dx === BAR_W - 1; - if (dy === 0) { r += end ? 10 : 34; g += end ? 8 : 28; b += end ? 6 : 24; } else { r -= end ? 34 : 18; g -= end ? 30 : 16; b -= end ? 26 : 14; } - if (e.T > 450 && incand) { const gi = glowIndex(e.T), w = GLOW_W[gi]; r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w; } - if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0; - px[sy * bw + sx] = (sm ? 0x04000000 : 0xff000000) | ((b > 255 ? 255 : b | 0) << 16) | ((g > 255 ? 255 : g | 0) << 8) | (r > 255 ? 255 : r | 0); - } - } - // grains in flight from the vacuum nozzle, a screw or a belt (rain and snow are drawn over the top, see drawRain) - if (!lens) for (const f of flyers) { - if (f.rain || (useFog && fogSeen(f.x, f.y) < 64)) continue; - const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0; - if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue; - const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8; - px[sy * bw + sx] = (sm ? 0x02000000 : 0xff000000) | (Math.max(0, Math.min(255, COLB[m] + v)) << 16) | (Math.max(0, Math.min(255, COLG[m] + v)) << 8) | Math.max(0, Math.min(255, COLR[m] + v)); - } - // blueprint ghosts: blocks waiting to be built - if (ghostBlocks.size) { - const bx0 = vx0 >> 2, by0 = vy0 >> 2, bx1 = (vx0 + bw) >> 2, by1 = (vy0 + bh) >> 2; - const pulse = (R.frame >> 3) & 3; - for (const [b, gv] of ghostBlocks) { - const gbx = b % BW, gby = (b / BW) | 0; - if (gbx < bx0 || gbx > bx1 || gby < by0 || gby > by1) continue; - const blocked = ghostBlocked.has(b), code = gv[0], v = gv[1]; - let tr = 90, tg = 190, tb = 230; - if (blocked) { tr = 230; tg = 70; tb = 60; } - else if (isWall(code) && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; } - const slope = isSlopeBelt(code), diagonal = code === BP_SCREW && (v >= 4 || straightScrewJoins(gbx, gby, v, ghostBlocks)); - const joins = slope ? slopeBeltConnections(gbx, gby, code, ghostBlocks) : diagonal ? (v >= 4 ? diagonalScrewJoins(gbx, gby, v, ghostBlocks) : straightScrewJoins(gbx, gby, v, ghostBlocks)) : 0; - const caps = code === BP_BELT || code === BP_FILTER ? flatBeltCaps(b, ghostBlocks) : 3; - for (let dy = slope ? -9 : diagonal ? -4 : 0; dy < (slope || diagonal ? 8 : BS); dy++) for (let dx = slope ? -5 : diagonal ? -4 : 0; dx < (slope ? 9 : diagonal ? 8 : BS); dx++) { - let sd = 0; - if (diagonal) { - if (v < 4 && ((v & 1) ? dx < 0 || dx >= BS : dy < 0 || dy >= BS)) continue; - const x = gbx * BS + dx, y = gby * BS + dy; - if (x < 0 || y < 0 || x >= W || y >= H) continue; - const i = y * W + x, n = (y >> 2) * BW + (x >> 2); - if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue; - sd = diagonalScrewShape(x + 0.5, y + 0.5, b, v, joins); - if (sd > 0) continue; - } - if (slope) { - const x = gbx * BS + dx, y = gby * BS + dy; - if (x < 0 || y < 0 || x >= W || y >= H) continue; - const i = y * W + x, n = (y >> 2) * BW + (x >> 2); - if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue; - sd = slopeBeltShape(x + 0.5, y + 0.5, b, joins, code); - const up = !isFilter(code) && slopeUphill(code, v), rib = up && slopePreviewCleat(x, y, b, code); - if (sd > 0 && !rib && (!up || slopeWallShape(x + 0.5, y + 0.5, b, code) > 0)) continue; - if (rib) sd = -0.1; - } - if (isSlope(code) && !slopeCell(code, dx, dy)) continue; - const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0; - if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue; - const k = sy * bw + sx, c = px[k]; - const edge = slope || diagonal ? sd > -0.8 : (dx === 0 && (caps & 1)) || dy === 0 || (dx === BS - 1 && (caps & 2)) || dy === BS - 1; - const a = edge ? (((dx + dy + pulse) & 3) === 0 ? 0.85 : 0.6) : 0.28; - const pr = c & 255, pg = (c >> 8) & 255, pb = (c >> 16) & 255; - px[k] = (sm ? 0x04000000 : 0xff000000) | ((pb + (tb - pb) * a) << 16) | ((pg + (tg - pg) * a) << 8) | (pr + (tr - pr) * a); - } - } - } - if (PERF_MARKS) { + const colorStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0; + const glowOn = SET.glow !== 'off' && !R.lens; + if (glowOn) R.glowPx.fill(0); + if (R.gpuOn) gpuOverrides(); + else for (let by = 0; by < bh; by++) for (let bx = 0; bx < bw; bx++) cellPixel(vx0 + bx, vy0 + by); + worldObjects(); + if (PERF_MARKS || SET.showPerf) { perfMarks.color = performance.now() - colorStart; perfMarks.prepare = colorStart - perfStart - perfMarks.light; } + if (R.gpuOn) { gpuDraw(lighting, glowOn); return; } if (sm) { smoothDraw(lighting, glowOn); return; } const uploadStart = PERF_MARKS ? performance.now() : 0; R.bctx.putImageData(R.img, 0, 0); diff --git a/src/shell.html b/src/shell.html index 04bd213..3d19a35 100644 --- a/src/shell.html +++ b/src/shell.html @@ -4,6 +4,7 @@ /*__CSS__*/ + diff --git a/src/styles/style.css b/src/styles/style.css index 21f747b..19b34d1 100644 --- a/src/styles/style.css +++ b/src/styles/style.css @@ -29,7 +29,7 @@ body { font-family: var(--font-ui); font-size: calc(13.5px * var(--ui)); line-height: 1.4; -webkit-user-select: none; user-select: none; touch-action: none; } -canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: none; } /* the game draws its own aim */ +canvas#gpu, canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: none; } /* the game draws its own aim */ /* overlay modes (thermal lens, info overlay): a coloured frame with corner brackets, a light wash for the info overlay so the world stays clear, and a named badge at the bottom of the screen */ #ovframe { position: fixed; inset: 0; pointer-events: none; opacity: 0; transition: opacity 0.2s; --ov: 92, 186, 166; } @@ -399,3 +399,6 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border- #hud.building:has(#minimap:not([hidden])) ~ #perf { bottom: calc(350px * var(--ui)); } #hud.building:has(#minimap:not([hidden])) ~ #saving { bottom: calc(390px * var(--ui)); } } + +canvas#gpu { pointer-events: none; } +canvas#gpu[hidden] { display: none; } diff --git a/src/ui/loop.js b/src/ui/loop.js index 85a23cc..be5fae0 100644 --- a/src/ui/loop.js +++ b/src/ui/loop.js @@ -101,7 +101,7 @@ function frame(now) { fpsSm = fpsSm * 0.95 + (1 / Math.max(0.001, dt)) * 0.05; if (SET.showPerf && R.frame % 15 === 0) { let aw = 0, ht = 0; for (let c = 0; c < NCH; c++) { if (awake[c]) aw++; if (hot[c]) ht++; } - $('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick (move ${(perfTimes.simMove || 0).toFixed(1)}, heat ${(perfTimes.heatPass || 0).toFixed(1)}, rest ${Math.max(0, simMs - (perfTimes.simMove || 0) - (perfTimes.heatPass || 0)).toFixed(1)})\nworld ${(perfTimes.drawWorld || 0).toFixed(1)} ms · light ${(perfTimes.computeLight || 0).toFixed(1)} ms · overlays ${(perfTimes.overlays || 0).toFixed(1)} ms\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`; + $('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick (move ${(perfTimes.simMove || 0).toFixed(1)}, heat ${(perfTimes.heatPass || 0).toFixed(1)}, rest ${Math.max(0, simMs - (perfTimes.simMove || 0) - (perfTimes.heatPass || 0)).toFixed(1)})\nworld ${(perfTimes.drawWorld || 0).toFixed(1)} ms · light ${(perfTimes.computeLight || 0).toFixed(1)} ms${R.gpuOn ? ` · overrides ${(perfMarks.override || 0).toFixed(1)} · upload ${(perfMarks.upload || 0).toFixed(1)} · GPU ${GPU.gpuMs === null ? "—" : GPU.gpuMs.toFixed(1)}` : ""} · overlays ${(perfTimes.overlays || 0).toFixed(1)} ms\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`; } } diff --git a/src/ui/settings.js b/src/ui/settings.js index 901cf76..82e4ce8 100644 --- a/src/ui/settings.js +++ b/src/ui/settings.js @@ -16,6 +16,7 @@ const SET_TABS = { { k: 'shake', label: 'Screen shake', type: 'toggle' }, ], Graphics: [ + { k: 'renderer', label: 'Renderer', d: 'WebGPU colours the pixel look on the graphics card. Smooth uses the original renderer.', type: 'select', opts: [['webgpu', 'WebGPU'], ['legacy', 'Legacy']] }, { k: 'look', label: 'Look', d: 'Smooth draws rounded, outlined ground, round grains and glossy liquids on the graphics card. Pixel is the original look, one square per grain.', type: 'select', opts: [['smooth', 'Smooth'], ['pixel', 'Pixel']] }, { k: 'lighting', label: 'Lighting', d: 'Full is colored light at grain resolution. Fast uses half resolution.', type: 'select', opts: [['off', 'Off'], ['fast', 'Fast'], ['full', 'Full']] }, { k: 'lightEvery', label: 'Light updates', type: 'select', opts: [[1, 'Every frame'], [2, 'Every 2nd frame'], [3, 'Every 3rd frame']] }, diff --git a/src/ui/state.js b/src/ui/state.js index d12a0f2..78c07dc 100644 --- a/src/ui/state.js +++ b/src/ui/state.js @@ -4,7 +4,7 @@ // ---------- Settings ---------- const SET_DEFAULT = { zoom: 12, integerScale: true, renderScale: 1, uiScale: 1, fpsCap: 0, showPerf: false, shake: true, cameraSmooth: 0.7, cameraLead: true, - look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true, + renderer: 'webgpu', look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true, minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3, volMaster: 0.8, volMusic: 0.5, volSfx: 0.8, muted: false, muteUnfocused: true, deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: 'ores', sv: 2, diff --git a/src/ui/title.js b/src/ui/title.js index 671637f..003e0ea 100644 --- a/src/ui/title.js +++ b/src/ui/title.js @@ -5,9 +5,11 @@ async function checkCapabilities() { try { const adapter = navigator.gpu && await navigator.gpu.requestAdapter(); CAPS.webgpu = !!adapter; + if (adapter) await gpuInit(adapter); + else SET.renderer = 'legacy'; const info = adapter && adapter.info; if (info) CAPS.adapterInfo = { vendor: info.vendor, architecture: info.architecture, device: info.device, description: info.description }; - } catch (e) { CAPS.webgpu = false; } + } catch (e) { CAPS.webgpu = false; SET.renderer = 'legacy'; } CAPS.ready = true; renderCapabilityNotice(); } diff --git a/test/play.py b/test/play.py index 3109872..94472b6 100644 --- a/test/play.py +++ b/test/play.py @@ -1308,10 +1308,10 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url): snapCamera(); GAME.hidePreviews = true; $('cards').replaceChildren(); }""") fp.wait_for_timeout(300) - sample = "(() => { drawWorld(); const k = (235 - R.vy0) * R.bw + 300 - R.vx0; return R.px[k] & 0xffffff; })()" + sample = "(() => { drawWorld(); const p=worldToScreenPx(300.5,235.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]|c[1]<<8|c[2]<<16; })()" check('unexplored pixel is black', fp.evaluate(sample) == 0) - check('sky starts seen and draws colour', fp.evaluate("(() => { const cy=R.camY; R.camY=0; drawWorld(); const c=R.px[20*R.bw+20]&0xffffff; R.camY=cy; drawWorld(); return fogSeen(300,0)===255 && c!==0x0a0a0c; })()")) - check('unexplored canvas pixel is black', fp.evaluate("(() => {const s=worldToScreenPx(300.5,235.5);return Array.from(R.ctx.getImageData(s.x,s.y,1,1).data).slice(0,3).every(v=>v<=12)})()")) + check('sky starts seen and draws colour', fp.evaluate("(() => { const cy=R.camY; R.camY=0; drawWorld(); const d=worldPixelContext().getImageData(20*R.zp,20*R.zp,1,1).data,c=d[0]|d[1]<<8|d[2]<<16; R.camY=cy; drawWorld(); return fogSeen(300,0)===255 && c>0 && c!==0x0a0a0c; })()")) + check('unexplored canvas pixel is black', fp.evaluate("(() => {const s=worldToScreenPx(300.5,235.5);return Array.from(worldPixelContext().getImageData(s.x,s.y,1,1).data).slice(0,3).every(v=>v<=12)})()")) check('hover and cursor share only Unexplored', fp.evaluate("JSON.stringify(hoverInfo(300,235)) === JSON.stringify({t1:'Unexplored',t2:[],t3:[]})")) fp.evaluate("GAME.aim = {x:300,y:235}; renderInfo()") check('info box says only Unexplored', fp.locator('#info').inner_text() == 'Unexplored') diff --git a/test/run_all.py b/test/run_all.py index 4874515..c07baaf 100644 --- a/test/run_all.py +++ b/test/run_all.py @@ -45,7 +45,7 @@ def main(): if browser: subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True) # one at a time: two headless browsers at once can starve each other and flake - for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 'firefox_smoke.py']: + for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 'firefox_smoke.py']: results.append(run_py(name)) bad = 0 for name, code, out, secs in results: diff --git a/test/t_gpu.py b/test/t_gpu.py new file mode 100644 index 0000000..44227ad --- /dev/null +++ b/test/t_gpu.py @@ -0,0 +1,81 @@ +"""WebGPU pixel parity, rare-cell selection, world replacement, resize and device loss.""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools')) +from use_venv import ensure_playwright; ensure_playwright() +from browser import launch, consume_gpu_option +gpu_mode = consume_gpu_option() +root = pathlib.Path(__file__).resolve().parent.parent +with launch(viewport={'width': 960, 'height': 600}) as (browser, pg, url): + errors = [] + pg.on('pageerror', lambda e: errors.append(str(e))) + pg.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None) + pg.add_init_script('requestAnimationFrame = () => 0;') + pg.goto(url); pg.evaluate('CAPS_CHECK') + if gpu_mode == 'soft' and not pg.evaluate('CAPS.webgpu'): + print('SKIP WebGPU parity: software browser has no WebGPU adapter') + sys.exit(0) + assert pg.evaluate('GPU.ok'), pg.evaluate('GPU.error') + pg.evaluate('generateWorld(424242, 640, 384, null); enterGame(); GAME.paused=true;') + pg.evaluate('() => {' + (root / 'tools/scenes/t88-renderer.js').read_text() + '}') + result = pg.evaluate("""() => { + SET.look='pixel'; SET.zoom=6; SET.glow='off'; SET.lighting='off'; + resizeRender(); bot.x=CAM[0]-bot.w/2; bot.y=CAM[1]-bot.h/2; snapCamera(); R.frame=1000; + const results=[]; + for(const rain of [0,0.7])for(const lit of [false,true])for(const lens of [false,true])for(const palette of ['inferno','ironbow','gray'])for(const fogOn of [false,true]) { + SET.lighting=lit?'full':'off'; SET.glow=lit?'low':'off'; + R.lens=lens; SET.thermalPalette=palette; SET.fog=fogOn; + R.rain=rain; SET.renderer='legacy';drawWorld(); const a=worldPixelContext().getImageData(0,0,960,600).data; + R.rain=rain; SET.renderer='webgpu';drawWorld(); const b=worldPixelContext().getImageData(0,0,960,600).data; + let max=0, sum=0, bad=0; + for(let i=0;i8;} + results.push({rain,lit,lens,palette,fogOn,max,mean:sum/(960*600*3),bad}); + } + R.lens=false; + return results; + }""") + for r in result: + assert r['bad'] == 0, r + print('PASS plain cells, built geometry, ghosts, rain and all thermal palettes match legacy within 8', len(result), 'configurations; max', max(r['max'] for r in result)) + assert pg.evaluate('GPU.overrideCount > 0 && GPU.overrideCount < R.bw*R.bh*0.25') + alpha = pg.evaluate('[R.gpuOn,GPU.ok,GPU.error,R.ctx.getImageData(10,10,1,1).data[3],worldPixelContext().getImageData(10,10,1,1).data[3],R.canvas.width,R.canvas.height]') + assert alpha[3:5] == [0,255], alpha + print('PASS most visible grains use GPU colouring; pixel reads include the opaque GPU world') + glow = pg.evaluate("""() => { + SET.fog=false; SET.lighting='off'; SET.glow='high'; R.lens=false; + spawnBar(BARFE,CAM[0]-30,CAM[1]+15,0,0,20); + const x=CAM[0]-64,y=CAM[1]-36; + setCell(y*W+x,MAGMA,1450,F_NAT); + flyers.push({x,y,m:COAL,rain:false}); + SET.renderer='legacy';drawWorld();const a=worldPixelContext().getImageData(0,0,960,600).data; + SET.renderer='webgpu';drawWorld();const b=worldPixelContext().getImageData(0,0,960,600).data; + let max=0;for(let i=0;i { + const a=worldPixelContext().getImageData(0,0,960,600).data; + SET.renderer='legacy';drawWorld();const b=worldPixelContext().getImageData(0,0,960,600).data; + SET.renderer='webgpu'; let max=0,nonblack=0; + for(let i=0;i0;for(let j=0;j<3;j++)max=Math.max(max,Math.abs(a[i+j]-b[i+j]));} + return {max,nonblack}; + }""") + assert parity['max'] <= 8 and parity['nonblack'] > 1000, parity + print('PASS world replacement and zoom reallocate textures and draw the new world', parity) + pg.evaluate('SET.look="smooth"; drawWorld();') + assert pg.evaluate('!R.gpuOn && R.smoothOn && GPU.canvas.hidden') + print('PASS smooth keeps the legacy path') + pg.evaluate('SET.look="pixel"; SET.renderer="webgpu"; PERF_MARKS=true; drawWorld();') + pg.wait_for_function('!GPU.timingBusy') + assert pg.evaluate('!GPU.queries || GPU.gpuMs>=0') + assert pg.evaluate('perfMarks.override>=0 && perfMarks.upload>=0') + print('PASS separate override/upload clocks and optional GPU timestamps') + pg.evaluate('GPU.device.destroy()') + pg.wait_for_function('!GPU.ok') + assert pg.evaluate('SET.renderer==="legacy"') + pg.evaluate('drawWorld()') + print('PASS device loss restores the legacy renderer') + assert not errors, errors + print('PASS no page or WebGPU validation errors') diff --git a/test/t_smooth.py b/test/t_smooth.py index 61363fe..68c9e17 100644 --- a/test/t_smooth.py +++ b/test/t_smooth.py @@ -21,7 +21,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): ev = pg.evaluate check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && SM.ok && R.smoothOn"), ev('[SET.look, SM.ok, SM.why, R.smoothOn]')) # the canvas shows the GPU image: sample a sky pixel and a ground pixel, they must differ and not be black - px = ev("""(() => { const c = R.ctx.getImageData(R.canvas.width >> 1, 4, 1, 1).data, g = worldToScreenPx(bot.x + 2, surfY[Math.floor(bot.x)] + 6), d = R.ctx.getImageData(Math.round(g.x), Math.round(g.y), 1, 1).data; return [Array.from(c), Array.from(d)]; })()""") + px = ev("""(() => { const c = worldPixelContext().getImageData(R.canvas.width >> 1, 4, 1, 1).data, g = worldToScreenPx(bot.x + 2, surfY[Math.floor(bot.x)] + 6), d = worldPixelContext().getImageData(Math.round(g.x), Math.round(g.y), 1, 1).data; return [Array.from(c), Array.from(d)]; })()""") check('it draws sky and ground', sum(px[0][:3]) > 200 and px[0][:3] != px[1][:3], px) pg.screenshot(path=str(out / 'smooth.png')) ev("player.lens = true"); pg.keyboard.press('h'); pg.wait_for_timeout(1500) @@ -43,8 +43,9 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): const i = y * W + x; setCell(i, STONE, 22, F_NAT); bgm[i] = 0; noiseB[i] = 128; } + GPU.world = null; // The fixture changed static noise in place. const fill = m => { for (let y = 100; y < 140; y++) for (let x = 100; x < 180; x++) setCell(y * W + x, m, 22); }; - const at = (x, y) => { const c = R.px[(y - R.vy0) * R.bw + x - R.vx0]; return (c & 255) + (c >>> 8 & 255) + (c >>> 16 & 255); }; + const at = (x, y) => { const p=worldToScreenPx(x+0.5,y+0.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]+c[1]+c[2]; }; const out = {}; for (const lens of [false, true]) { R.lens = lens; SET.look = 'pixel'; fill(RSTONE); drawWorld(); @@ -59,7 +60,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): fill(RSTONE); SET.look = 'smooth'; SM.scale = 1; drawWorld(); const sample = (x, y) => { const p = worldToScreenPx(x, y); - const c = R.ctx.getImageData(Math.floor(p.x), Math.floor(p.y), 1, 1).data; + const c = worldPixelContext().getImageData(Math.floor(p.x), Math.floor(p.y), 1, 1).data; return c[0] + c[1] + c[2]; }; out.smooth = [sample(140.5, 140.08), sample(140.5, 142.5)]; @@ -140,8 +141,8 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): const x=cell%W,y=(cell/W)|0,control=80*W+180; bot.x=118;bot.y=78;snapCamera(); setCell(cell,SAND,22,0);setCell(control,SAND,22,0); - noiseB[cell]=noiseB[control]=128; - const at=i=>R.px[(((i/W)|0)-R.vy0)*R.bw+i%W-R.vx0]; + noiseB[cell]=noiseB[control]=128; GPU.world=null; + const at=i=>{ if(R.smoothOn) return R.px[(((i/W)|0)-R.vy0)*R.bw+i%W-R.vx0]; const p=worldToScreenPx(i%W+0.5,((i/W)|0)+0.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]|c[1]<<8|c[2]<<16; }; SET.look='pixel';drawWorld();const colors=[at(cell)&0xffffff,at(control)&0xffffff]; SET.look='smooth';drawWorld();const grainClass=at(cell)>>>24; return {colors,grainClass}; @@ -162,7 +163,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): for(const look of ["pixel","smooth"]) { SET.look=look;SM.scale=1;drawWorld(); const bad=[], copper=c=>c[0]>c[1]*1.15 && c[1]>c[2]*1.1; - const sample=(x,y)=>{const p=worldToScreenPx(x,y);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);}; + const sample=(x,y)=>{const p=worldToScreenPx(x,y);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);}; for(const j of JOIN_CASES.filter(j=>j.code===BP_SCREW)) { const slope=j.v===4 || j.v===6?-1:1, side=j.end?1:-1; const joins=diagonalScrewJoins(j.bx,j.by,j.v); @@ -211,7 +212,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): const b=j.by*BW+j.bx,nb=j.ny*BW+j.nx; const plan=new Map([[b,[BP_SCREW,j.v]],[nb,[BP_SCREW,j.straight]]]); removeBlock(j.bx,j.by);removeBlock(j.nx,j.ny); - const sample=(x,y)=>{const p=worldToScreenPx(j.nx*4+x,j.ny*4+y);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data).join(',');}; + const sample=(x,y)=>{const p=worldToScreenPx(j.nx*4+x,j.ny*4+y);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data).join(',');}; SM.scale=1;drawWorld();const bg=sample(ux,uy),empty=sample(2,2); for(const [n,piece] of plan)ghostBlocks.set(n,piece); drawWorld(); @@ -236,7 +237,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): try { const j=JOIN_CASES.find(j=>j.v===5 && j.end===1 && !j.vertical && j.spot===2 && j.code===BP_SCREW); const bx=j.nx+1,by=j.ny; - const sample=()=>{const p=worldToScreenPx(bx*4,by*4);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),4*R.zp,4*R.zp).data).join(',');}; + const sample=()=>{const p=worldToScreenPx(bx*4,by*4);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),4*R.zp,4*R.zp).data).join(',');}; drawWorld();const plain=sample(),bad=[]; for(const m of [WOOD,LEAVES]) { for(let y=by*4;yj.v===4&&j.end===0&&j.code===BP_BELT&&j.spot===0); try { slopeDetailAt=()=>0;performance.now=()=>frozen; - const sample=()=>{const p=worldToScreenPx(j.bx*BS+0.4,j.by*BS+2.5);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);}; + const sample=()=>{const p=worldToScreenPx(j.bx*BS+0.4,j.by*BS+2.5);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);}; for(const look of ['pixel','smooth']) { SET.look=look;SM.scale=1;drawWorld();const joined=sample(); removeBlock(j.nx,j.ny);drawWorld();const free=sample(); diff --git a/tools/perf-measure.js b/tools/perf-measure.js index 96b4d76..0e7488e 100644 --- a/tools/perf-measure.js +++ b/tools/perf-measure.js @@ -29,7 +29,7 @@ function perfSim(names, scene, w, h) { return { total_ms: total, ticks: n, start, end: perfState(), steps, rest_ms: total - Object.values(data).reduce((s, d) => s + d.ms / n, 0) }; } -function perfRender(lighting, overlays) { +async function perfRender(lighting, overlays) { SET.lighting = lighting; PERF_MARKS = true; try { @@ -48,13 +48,22 @@ function perfRender(lighting, overlays) { // Exercise the actual overlay calls and their arguments/conditions in frame(). PERF_MARKS = false; for (let k = 0; k < 5; k++) frame(lastT + 1000 / 60); - const data = {}, restore = perfWrap(overlays, data); + const data = {}, restore = perfWrap(overlays, data), frameStart = performance.now(); try { for (let k = 0; k < n; k++) frame(lastT + 1000 / 60); } finally { restore(); } - return { drawWorld_ms: draw, computeLight_ms: light, marks_ms: marks, + const frame_ms = (performance.now() - frameStart) / n; + // One completed timestamp sample for this configuration, outside the CPU measurements. + let gpu_ms = null; + if (R.gpuOn && GPU.queries) { + await GPU.timingPromise; + PERF_MARKS = true; drawWorld(); + await GPU.timingPromise; + PERF_MARKS = false; gpu_ms = GPU.gpuMs; marks.gpu = gpu_ms; + } + return { frame_ms, drawWorld_ms: draw, computeLight_ms: light, marks_ms: marks, overlays: Object.fromEntries(Object.entries(data).map(([key, d]) => [key, { ms_per_frame: d.ms / n, calls: d.calls }])), overlay_ms: Object.values(data).reduce((sum, d) => sum + d.ms / n, 0), - scale: SM.scale, actual_look: SET.look }; + scale: SM.scale, actual_look: SET.look, renderer: R.gpuOn ? 'webgpu' : 'legacy', override_cells: GPU.overrideCount || 0, gpu_ms }; } finally { PERF_MARKS = false; } } function perfRun(seconds) { @@ -72,7 +81,7 @@ function perfRun(seconds) { requestAnimationFrame(frame); setTimeout(() => { window.__perfHalt(); PERF_FRAME = null; GAME.paused = true; - resolve({ samples, end: perfState(), scale: SM.scale, actual_look: SET.look }); + resolve({ samples, end: perfState(), scale: SM.scale, actual_look: SET.look, renderer: R.gpuOn ? 'webgpu' : 'legacy', override_cells: GPU.overrideCount || 0, gpu_ms: R.gpuOn ? GPU.gpuMs : null }); }, seconds * 1000); }); } diff --git a/tools/perf.py b/tools/perf.py index ade527f..2406d8b 100644 --- a/tools/perf.py +++ b/tools/perf.py @@ -54,14 +54,14 @@ def report(result, baseline=None): m = result['machine'] print(f"GPU: {m['renderer']}\nCores: {m['cores']} | isolated: {m['crossOriginIsolated']} | navigator.gpu: {m['webgpu']}") print(f"Viewport {result['options']['viewport']} | world {result['options']['world']} | seed 424242 | ms (CPU wall time)") - print('\nScene Look Zoom Light Grains World Light Prep Colour Upload Blit Overlays Scale') + print('\nScene Look Zoom Light Grains World Frame Light Prep Colour Upload Blit Overlays Scale') for scene, data in result['scenes'].items(): for key, view in data['views'].items(): look, zoom = key.split('/') for light, r in view['lighting'].items(): p = r['marks_ms'] print(f"{scene:8} {look:6} {zoom:>4} {light:>5} {view['grains']:6d} " - f"{r['drawWorld_ms']:7.2f} {p['light']:6.2f} {p['prepare']:5.2f} {p['color']:6.2f} " + f"{r['drawWorld_ms']:7.2f} {r.get('frame_ms', 0):6.2f} {p['light']:6.2f} {p['prepare']:5.2f} {p['color']:6.2f} " f"{p['upload']:6.2f} {p['blit']:6.2f} {r['overlay_ms']:8.3f} {r['scale']:5.2f}") print('\nSimulation: average ms/tick (periodic work amortized over 240 ticks)') names = list(next(iter(result['scenes'].values()))['sim']['steps']) @@ -125,8 +125,10 @@ def main(): options = dict(viewport=list(args.viewport), world=list(args.world), zooms=zooms, looks=looks, scenes=scenes, seconds=args.seconds) baseline = json.loads(args.compare.read_text()) if args.compare else None - if baseline and (baseline.get('schema') != 1 or baseline['options'] != options): - ap.error('baseline schema/options differ; use the same matrix, dimensions and seconds') + if baseline and (baseline.get('schema') != 1 + or any(baseline['options'].get(k) != v for k, v in options.items() if k not in ('looks', 'scenes', 'zooms')) + or any(not set(options[k]).issubset(baseline['options'][k]) for k in ('looks', 'scenes', 'zooms'))): + ap.error('baseline schema/options differ; use a subset of the baseline matrix with the same dimensions and seconds') ensure_playwright() started = time.monotonic() subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True, stdout=sys.stderr) @@ -154,6 +156,9 @@ def main(): const id = raf(t => { pending.delete(id); f(t); }); pending.add(id); return id; };''') pg.goto(url) + pg.evaluate('CAPS_CHECK') + if not pg.evaluate('GPU.ok'): + raise RuntimeError('WebGPU unavailable: ' + pg.evaluate('GPU.error')) pg.add_script_tag(content=(ROOT / 'tools/perf-scenes.js').read_text()) pg.add_script_tag(content=(ROOT / 'tools/perf-measure.js').read_text()) result['machine'] = pg.evaluate('''() => { @@ -162,6 +167,7 @@ def main(): return { renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER), vendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR), cores: navigator.hardwareConcurrency, crossOriginIsolated, webgpu: !!navigator.gpu, + adapter: CAPS.adapterInfo, timestamp_query: !!GPU.queries, user_agent: navigator.userAgent, dpr: devicePixelRatio }; }''') if not result['machine']['crossOriginIsolated']: @@ -185,6 +191,8 @@ def main(): view['lighting'][light] = pg.evaluate('([l,o]) => perfRender(l,o)', [light, overlays]) if view['lighting']['full']['actual_look'] != look or view['lighting']['off']['actual_look'] != look: raise RuntimeError('Look fell back while measuring') + if look == 'pixel' and any(r['renderer'] != 'webgpu' for r in view['lighting'].values()): + raise RuntimeError('WebGPU fell back while measuring: ' + pg.evaluate('GPU.error')) data['views'][f'{look}/{zoom:g}'] = view for look in looks: for zoom in [2, 6]: @@ -193,6 +201,8 @@ def main(): r = pg.evaluate('seconds => perfRun(seconds)', args.seconds) if r['actual_look'] != look: raise RuntimeError('Look fell back during real run') + if look == 'pixel' and r['renderer'] != 'webgpu': + raise RuntimeError('WebGPU fell back during real run: ' + pg.evaluate('GPU.error')) for key in ['interval', 'work', 'sim']: r[key + '_ms'] = quantiles([s[key] for s in r['samples']]) data['runs'][f'{look}/{zoom}'] = r diff --git a/tools/scenes/t88-renderer.js b/tools/scenes/t88-renderer.js new file mode 100644 index 0000000..0b7ac9c --- /dev/null +++ b/tools/scenes/t88-renderer.js @@ -0,0 +1,29 @@ +// Paused comparison scene: ordinary grains, fog, heat, trees and exceptional geometry. +const cx = (W / 3) & ~3, cy = 180; +window.CAM = [cx, cy]; +SET.fog = true; SET.shake = false; SET.cameraLead = false; +weather.next = 1e15; R.rain = 0; +for (let y = cy - 52; y < cy + 52; y++) for (let x = cx - 90; x < cx + 90; x++) { + const i = y * W + x; + setCell(i, y >= cy + 24 ? STONE : EMPTY, 20, y >= cy + 24 ? F_NAT : 0); + bgm[i] = part[i] = mach[i] = occ[i] = 0; blockP[(y >> 2) * BW + (x >> 2)] = 0; +} +for (let x = cx - 90; x < cx + 90; x++) { surfY[x] = cy + 16; dirtY[x] = cy + 30; graniteY[x] = cy + 38; basaltY[x] = cy + 46; } +const fill = (x0,y0,w,h,m,T=20) => { for(let y=y0;y{const e=placeBlock(code,v,(cx+dx)>>2,(cy+dy)>>2,true);if(e)throw Error(e);}; +for(let dx=-36;dx<-12;dx+=4)place(BP_BELT,1,dx,-12); +place(BP_SCREW,1,-4,-12); place(BP_SCREW,4,0,-12); +place(BP_BELT_UP,1,16,-12);place(BP_BELT_UP,1,20,-16); +place(BP_PIPE,0,32,-12);place(BP_PIPE,0,36,-12); +addMold('ingot',(cx+8)>>2,(cy+8)>>2,RSTONE,true); +spawnBar(BARCU,cx-25,cy-16,0,0,700); +flyers.push({x:cx-8,y:cy-18,m:SAND,rain:false}); +ghostBlocks.set(((cy-4)>>2)*BW+((cx-4)>>2),[BP_WALL,RSTONE]); +for(let j=0;j { SET.renderer = r; if (r === \'webgpu\' && !GPU.ok) throw Error(GPU.error || \'WebGPU unavailable\'); }', a.renderer) pg.wait_for_timeout(300) # pause the moment the world exists, so the shot depends only on the seed, the setup # code and --ticks (simTick is deterministic), not on how fast the browser ran @@ -99,6 +103,8 @@ def main(): pg.evaluate("() => { window.__frozenNow = 5000; window.requestAnimationFrame = () => 0; }") pg.wait_for_timeout(100) pg.evaluate("() => { R.frame = 999; if (typeof SM !== 'undefined') SM.t0 = 0; frame(lastRender + 1000); }") + if a.look == 'pixel' and a.renderer == 'webgpu' and not pg.evaluate('R.gpuOn'): + raise RuntimeError('WebGPU fell back: ' + pg.evaluate('GPU.error')) kw = {} if a.clip: x, y, w, h = (float(v) for v in a.clip.split(',')) @@ -107,7 +113,7 @@ def main(): pg.screenshot(path=a.out, **kw) note = pg.evaluate("SET.look === 'smooth' ? (SM.ok ? 'smooth look' : 'smooth look FAILED: ' + SM.why) : 'pixel look'") b.close() - print(f'saved {a.out} ({note})') + print(f"saved {a.out} ({note}, {a.renderer if a.look == 'pixel' else 'legacy'})") for e in errors: print('ERROR', e) sys.exit(1 if errors else 0) diff --git a/tools/test_perf.py b/tools/test_perf.py index 5a162be..40b89a6 100644 --- a/tools/test_perf.py +++ b/tools/test_perf.py @@ -38,6 +38,7 @@ def main(): pg.on('pageerror', lambda e: errors.append(str(e))) pg.add_init_script('window.requestAnimationFrame = () => 0;') pg.goto(url) + pg.evaluate('CAPS_CHECK') pg.add_script_tag(content=(ROOT / 'tools/perf-scenes.js').read_text()) assert pg.evaluate('crossOriginIsolated') # Compare to the unwrapped functions, and hash the byte representation of all grid state. @@ -66,8 +67,8 @@ def main(): performance.now = () => 5000; R.rain = rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + R.bw / 2)))); try { - PERF_MARKS = false; drawWorld(); const a = R.canvas.toDataURL(); - PERF_MARKS = true; drawWorld(); const b = R.canvas.toDataURL(); + PERF_MARKS = false; drawWorld(); const a = worldPixelContext().canvas.toDataURL(); + PERF_MARKS = true; drawWorld(); const b = worldPixelContext().canvas.toDataURL(); return a === b; } finally { PERF_MARKS = false; performance.now = now; } }''', look)