diff --git a/README.md b/README.md index f57d75a..4226bc7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ A falling-sand factory game in the browser, in the style of Sandustry. +WebGPU is required (Chrome or Edge with a supported graphics adapter). Both looks use WebGPU; +there is no fallback renderer. Browsers without an adapter, including Firefox on Linux in our +checks, show a notice on the start screen. Serve the page over HTTP as described below. + Every grain of sand, ore, water and fire is simulated. You fly a small bot over a side-view world, dig with a resonance drill and vacuum up what breaks loose. You build furnaces from blocks, melt ore with coal and air, and cast the metal in molds. Then you sell bars, alloys and @@ -51,7 +55,8 @@ The controls are in the in-game notebook (N) under Controls. The basics: ## Develop -You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots: +You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots. +The golden-image tests also use ImageMagick (`magick`): ```sh uv venv .venv diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 1f56a9c..7f2e5a6 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -15,7 +15,7 @@ src/manifest.json the build order: simulation files, then browser files src/shell.html page markup (HUD, menus, dialogs); CSS and JS are put in at the markers src/styles/ base styles and the dev console panel src/sim/ the simulation. No DOM. The headless tests load only these files. -src/render/ drawing: the pixel look (CPU) and the smooth look (WebGL2), overlays +src/render/ drawing: WebGPU pixel and smooth looks, rare CPU overrides and overlays src/ui/ the browser shell: input, HUD, build menu, notebook, settings, saves, audio, the frame loop test/ headless suites (t_*.js) and their harness; browser tests (play.py, @@ -149,23 +149,19 @@ The frame loop applies speed and queued steps while keeping the per-frame tick l ## Drawing -`SET.renderer` selects WebGPU or Legacy for either look. `src/render/cell.js` holds the shared -CPU rules; `objects.js` adds bars, flyers and blueprint ghosts. The GPU canvas sits behind the -transparent game canvas, which draws the existing overlays (machines, molds, bot, rain, arrows). +Both looks require WebGPU. `cell.js` colours rare built geometry and `objects.js` adds bars, +flyers and blueprint ghosts. The GPU canvas sits behind the transparent overlay canvas. -- **WebGPU** (`src/render/gpu/`): `textures.js` uploads visible world rectangles, `colour.js` - colours plain grains, and `draw.js` selects rare CPU override blocks and submits the passes. - `light.js` computes lighting; the CPU `computeLight` is never called by this path. - - Pixel uses nearest upscaling and separable GPU bloom (`shaders.js`). - - Smooth adds class, back colour/sky flag, tree/geometry tags and occupancy field outputs to - colour compute. Only this look uploads the CPU back/tree/field overrides and presence mask. - - `smooth-shader.js` and `smooth-slope.js` literally port the legacy GLSL shapes to WGSL. - `smooth.js` creates the smooth pipeline, field mip passes and bindings. Field AO samples - level 2.2; glow retains the legacy nine-tap filter. WebGPU smooth always draws full resolution. -- **Legacy**: `drawWorld` fills every cell on the CPU, and `render/light.js` computes lighting. - Pixel uses Canvas2D; smooth (`render/smooth.js`, `smooth-shaders.js`, `slope-belt-shader.js`) - shades the four inputs with WebGL2 and keeps its adaptive resolution/fallback. Both smooth - renderers share `smoothSurface` for the cached parallax horizon. +- `gpu/device.js` compiles every pipeline asynchronously at startup, submits both looks during + world loading, and handles one device recreation followed by an autosaved Reload notice. +- `gpu/textures.js` owns world/view textures and bindings; `gpu/uploads.js` merges dirty 32x32 + chunks into horizontal strips, retaining clean chunks across camera moves. Simulation writes + mark `renderDirty` in `sim/world.js`; hot chunks include the conduction halo. +- `gpu/colour.js` colours plain grains. Smooth uses a second pass for its four outputs so no + pipeline exceeds four storage textures. `gpu/cracks.js` supplies drill contour shading. +- `gpu/light.js` computes sky, heat emission, lamp and light propagation. No CPU light sweep. +- `gpu/shaders.js` provides pixel upscaling and bloom. `gpu/smooth*.js` shapes smooth grains, + field AO and glow; `gpu/surface.js` caches the parallax horizon. - Smooth classes are set in `cell.js`: 4 crisp built piece, 6 wall tile, 7/8 belt, 9–12 screw, 13/14 filter, 19–22 diagonal screw, 23–30 slope belt/filter, 31/32 slope sidewall and cleats. `diagonal-screws.js` and `slope-belts.js` share the simulation join geometry with pixel colours, @@ -248,22 +244,22 @@ Starter furnace designs and the milestone list shown in the notebook. - functions: smelterBlueprint - variables: STARTER_DESIGNS, MILESTONES -### src/sim/world.js (107 lines) +### src/sim/world.js (113 lines) World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking. -- functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx -- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats +- functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, renderDirtyXY, renderDirtyIdx, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx +- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, renderDirty, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats ### src/sim/fog.js (52 lines) Exploration only: never changes grains or the random stream. - functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll - variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH -### src/sim/cells.js (225 lines) +### src/sim/cells.js (227 lines) Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove). - functions: setCell, swapCells, move, enter, enterFree, powder, passable, liquid, gas, wakeBotPowder, simMove - variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL -### src/sim/heat.js (266 lines) +### src/sim/heat.js (271 lines) Heat: conduction, melting and freezing, reactions, fire and air, steel. - functions: curAir, airflowAt, alloyMix, airy, hasO2, condPair, heatPass, heatChunk, neighborDir, react, char, quench, burningNear, burn, steelCheck - variables: POROUS, SOIL, HOTLIQ, REACT_MIN, ALLOY @@ -297,7 +293,7 @@ Travel-directed transport ends. Precomputed probes; world/plan queries allocate - functions: pieceEnds, joinCandidate, endJoin, pieceJoins, endAt, joinIsDrop, joinSnap, nearMiss - variables: JOIN_ENDS, NO_PIECE -### src/sim/conveyors.js (212 lines) +### src/sim/conveyors.js (213 lines) How belts, filters and screw conveyors move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full. - functions: conveyorStep, carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, screwAccepts, straightScrewNext, diagonalScrewNext - variables: CARRY_DEPTH, filterR, filterL, filtersDirty @@ -312,7 +308,7 @@ Cleats are rubber ribs standing up from a sloped belt's face, one every CLEAT_SP - functions: ribMove, cleatFoot, ribCells, slopeUphill, slopeFaceAt, pushRow, scoop, pocketRoom, advanceCleat, slopeRunKey, slopeCleatsTick, rebuildCleatMask, expelFromRib, screenNudge, wakeCleatLoad - variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatChunks, cleatMask, cleatCells, slopePhase, ribLog -### src/sim/blocks.js (235 lines) +### src/sim/blocks.js (237 lines) Building blocks: placing, integrity, removing, lines and rectangles of blocks. - functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, blockOwnsCell, clearStraySeals, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks @@ -336,7 +332,7 @@ The resonance drill: tuned pulses that shatter one material. - functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick, pingRadius, resonancePing - variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF, PING_VEINS -### src/sim/nature.js (168 lines) +### src/sim/nature.js (170 lines) Trees (background layer), acorns, saplings, and waking freezing water. - functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree - variables: treeBases, saplings, treeDraw @@ -396,15 +392,11 @@ Saving and loading a world (serializeWorld / deserializeWorld) and old-save migr - functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick - variables: SAVE_VERSION, TOFF -### src/render/setup.js (200 lines) +### src/render/setup.js (198 lines) Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow, thermal palettes, pipes, trees, sky), canvas setup and the camera. - functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont - variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B -### src/render/light.js (103 lines) -Propagated coloured light from glowing and burning cells. -- functions: builtForLight, looseForLight, computeLight - ### src/render/slope-belts.js (167 lines) Drawing only: the face stays on its simulation cells; the band hangs below it. - functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeWallShape, slopeWallAt, slopeDetailAt, slopePreviewCleat @@ -415,14 +407,19 @@ 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/cell.js (272 lines) -Shared CPU colour rules, used by legacy and the rare-cell WebGPU override. -- functions: cellPixel +### src/render/cell.js (276 lines) +Rare-cell shading also treats built pieces as transparent surroundings. +- functions: builtForShade, cellPixel ### src/render/objects.js (80 lines) Loose bars, flying grains and queued blueprint blocks in the grain buffer. - functions: worldObjects +### src/render/gpu/cracks.js (79 lines) +The drill's three seeded Perlin contour fields, uploaded once with the material tables. +- functions: gpuCrackTables +- variables: GPU_CRACKS, GPU_CRACK_COLOUR, GPU_CRACK_DRAW + ### src/render/gpu/colour.js (101 lines) The plain-cell rules mirror cellPixel; built geometry stays in the CPU override. - functions: gpuColourShader @@ -445,26 +442,34 @@ Smooth slope geometry and cleats, ported from slope-belt-shader.js. Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin. - variables: GPU_SMOOTH -### src/render/gpu/smooth.js (47 lines) +### src/render/gpu/surface.js (5 lines) +- functions: smoothSurface +- variables: SM + +### src/render/gpu/smooth.js (48 lines) Field mip levels preserve the legacy trilinear AO sample at level 2.2. - functions: gpuSmoothInit, gpuSmoothTextures, gpuSmoothBindings, gpuSmoothUpload, gpuSmoothMips - variables: GPU_FIELD_MIP -### src/render/gpu/device.js (48 lines) -Async startup shares the title screen's adapter; failures leave a usable legacy renderer. -- functions: gpuInit, gpuFail, worldPixelContext -- variables: GPU +### src/render/gpu/device.js (89 lines) +All pipelines compile during startup; rendering never falls back. +- functions: gpuLimitsOK, gpuInit, gpuFail, gpuRecover, gpuWarmWorld, worldPixelContext +- variables: GPU_LIMITS, GPU -### src/render/gpu/textures.js (104 lines) -Whole-world allocation, direct strided uploads; no cell packing or dirty tracking. +### src/render/gpu/textures.js (98 lines) +Whole-world textures retain clean chunks across camera moves. - functions: gpuTexture, gpuWrite, gpuBuffer, gpuWorldTextures, gpuViewTextures, gpuBindings, gpuUpload -### src/render/gpu/draw.js (83 lines) +### src/render/gpu/uploads.js (48 lines) +Upload full chunks so scrolling within a resident chunk needs no extra copy. +- functions: gpuUploadChunks, gpuUploadStrip, gpuCheckUploads + +### src/render/gpu/draw.js (92 lines) Only exceptional blocks and their geometric neighbours run the CPU colour rules. - functions: gpuOverrideHas, gpuOverrides, gpuDraw, gpuPresent - variables: GPU_PATTERN -### src/render/world.js (85 lines) +### src/render/world.js (46 lines) Scale colour without changing the smooth-look class. - functions: fogColor, drawWorld @@ -477,19 +482,6 @@ Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, t - functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles - variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS -### src/render/smooth.js (108 lines) -Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution. -- functions: smCompile, smoothAvailable, smUpload, smoothSurface, smoothDraw, smoothPace -- variables: SM, smBytes - -### src/render/slope-belt-shader.js (111 lines) -Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js. -- variables: SM_SLOPE_HELPERS, SM_SLOPE_DRAW - -### src/render/smooth-shaders.js (377 lines) -Smooth shaders: vertex and fragment programs for the rounded world look. -- variables: SM_VS, SM_FS - ### src/ui/audio.js (230 lines) Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples. - functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler @@ -500,26 +492,26 @@ 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 (76 lines) +### src/ui/title.js (77 lines) Startup capabilities and title art. - functions: checkCapabilities, renderCapabilityNotice, canStartGame, buildTitleCells, drawTitle -- variables: REQUIRE_GPU_AND_THREADS, CAPS, CAPS_CHECK, FONT5, TITLE +- variables: REQUIRE_WEBGPU, REQUIRE_THREADS, CAPS, CAPS_CHECK, FONT5, TITLE ### src/ui/saves.js (224 lines) Saves in IndexedDB, world files (export / import) and the blueprint library. - functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, cleanWorldName, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, packBuffers, unpackBuffers, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode - variables: DB, worldIdTime, worldIdCounter, worldWrites, savingCount -### src/ui/claims.js (145 lines) +### src/ui/claims.js (146 lines) Shared player state; the world save remains a complete standalone fallback. - functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab - variables: career, claimBusy -### src/ui/session.js (160 lines) +### src/ui/session.js (162 lines) 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 (126 lines) +### src/ui/settings.js (123 lines) The settings screen. - functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe - variables: SET_TABS, setTab @@ -593,10 +585,9 @@ Gamepad input. Cursor previews: shared grid helpers, air streaks and tool preview dispatch. - functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock -### src/ui/preview-drill.js (107 lines) -Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines run on unbroken from one cell to the next and look like real fractures, not a pattern. -- functions: crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim -- variables: CRACK_N, CRACK_LV +### src/ui/preview-drill.js (67 lines) +Drill pulse wavefronts and aiming fan. The pulses in flight: a wavefront running out from the face of the bot, fading as it goes +- functions: pulseRGB, fanPoints, drawDrillPulses, drawDrillAim ### src/ui/preview-selection.js (81 lines) Selection previews: labelled tags, copy and cut boxes, and paste ghosts. a small labelled tag drawn on the world (screen pixels) @@ -616,7 +607,7 @@ Shared world colours, minimap and full map. Browser-only clocks; simulation code stays usable without a browser or a clock. - variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast -### src/ui/loop.js (148 lines) +### src/ui/loop.js (143 lines) The frame loop and boot. - functions: scanVisible, frame, boot - variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT @@ -627,12 +618,14 @@ The frame loop and boot. - `test/furnace_play.js`: Fires a starter furnace design repeatedly the way a player would, without cleaning it out between firings, to see whether it clogs. Usage: node test/furnace_play.js [design] [ore] [coal] [firings] [roll] [mode] mode: 'mix' (even ore/coal mix blown in), 'layers' (ore first, coal on top), 'vent' (blown in from above through the vent) - `test/gpu-readback.js`: Read the four actual smooth textures without adding COPY_SRC usage to game resources. - `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script. +- `test/light-cases.js`: Lighting behaviour checks executed by the GPU browser harness. +- `test/light-readback.js`: Read float source channels, including transmittance, from the WGSL light pass. - `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'. - `test/run_all.py`: Run the test suites and report. - `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports. - `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load. - `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning. -- `test/t_caps.py`: Capability detection, title warnings and the future startup gate. +- `test/t_caps.py`: Capability detection, title notices and the required-WebGPU startup gate. - `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters. - `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen. - `test/t_cursor.js`: Ore identification, cached connected veins, ping timing, settings and label lifetime. @@ -644,9 +637,10 @@ 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_gpu.py`: Dirty uploads, default limits, warm pipelines, world replacement and device recovery. +- `test/t_gpu_golden.py`: Frozen legacy references, captured before retirement; compare the WebGPU world only. +- `test/t_gpu_light.py`: The former CPU light behavioural checks, now read from the actual GPU light grid. - `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. - `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe. - `test/t_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling. @@ -654,12 +648,13 @@ The frame loop and boot. - `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way. - `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore. - `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation. +- `test/t_render_dirty.js`: Render invalidation must not depend on whether a grain moved or the chunk stayed awake. - `test/t_silo_contents.js`: Stored snow keeps its identity and remains in the world's water budget. - `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly. - `test/t_slope_belt_art.js`: Drawing geometry against real parts, without changing transport. - `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls. - `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence. -- `test/t_smooth.py`: The smooth look: the WebGPU pass compiles, draws, falls back cleanly, and the thermal lens still works. +- `test/t_smooth.py`: The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works. - `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders. - `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds. - `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing. diff --git a/docs/TESTING.md b/docs/TESTING.md index 88e526e..430b1ef 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -67,30 +67,42 @@ diff -r /tmp/before /tmp/after gamepad and world sizes. Screenshots go to `shots/play/`. Pixel look by default; `python3 test/play.py shots/play smooth` uses the smooth look. -- `test/t_smooth.py` checks that the WebGPU smooth look compiles and draws, compares legacy at half resolution, and falls back - to pixels cleanly, and that the thermal lens works. -- Run them one at a time. Two headless browsers at once can starve each other, and - `t_smooth.py`'s half-resolution check then fails. `run_all.py` runs them one after the other. +- `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens. +- `test/t_gpu_golden.py` compares 28 cropped legacy reference PNGs (384x288, under + `test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes, + both looks at zoom 12 and 2. ImageMagick decodes RGB; the per-channel tolerance is 8, + mean RGB error must stay below 0.5/255 and fewer than 0.3% of pixels may exceed 8 + (smooth AO/edge rounding). These are frozen pre-removal legacy images, not generated + from the new renderer. Inspect the matching `shots/golden-*.png` after visible changes. +- `test/t_gpu.py` checks default limits (four storage textures per stage), dirty-upload + readback after 240 busy ticks with scrolling and both looks, exact dirty/full image equality + with a frozen clock, world replacement, transparent overlays, drill contours, shader compilation timing, successful device recreation and failed recovery with + an autosave and Reload. `gpuCheckUploads()` reads actual resident GPU texture bytes and + compares them to the full CPU arrays, including Float32 temperatures, machine and block data. +- `test/t_render_dirty.js` checks invalidation of sleeping chunks, heat edges, swaps, fog and trees. +- The old CPU light checks now run as `test/t_gpu_light.py` / `test/light-cases.js`, reading the + actual WGSL float light grid. Transmission tolerance is 0.001 because the GPU work textures + store float16 (the removed CPU implementation used float32). Behavioural expectations remain. +- Browser suites run one at a time; concurrent GPU work distorts timing and can starve tests. - Rebuild first (`python3 build.py`); `run_all.py --browser` rebuilds for you. All browser tools use `tools/browser.py` and `tools/server.py`: a free-port background HTTP server serves the built page with COOP/COEP headers and shuts down after the browser. No `file://` loads. - Choose Chromium graphics with `--gpu real|soft` or `DEEPGRAIN_GPU=real|soft` (the command line wins). This works for the suite runner, individual browser tests, screenshots and profiler. - Real mode uses ANGLE Vulkan on the host GPU; soft uses SwiftShader and sets - `window.__allowSoftwareGL` before load. The explicit software-fallback test still uses soft - mode with that override off. Firefox uses its own default graphics backend. + Real mode uses ANGLE Vulkan on the host GPU; soft uses SwiftShader. + Unsupported software adapters show the same startup notice. Firefox uses its own default graphics backend. The default is **real**, including the profiler and `caps.py`. No colour tolerances were changed. The play test holds the slow-save fixture open during its screenshot, waits for tool/selection UI updates, disables camera lead after testing it, and gives the gamepad drill a known reachable target; these checks do not depend on GPU speed or earlier digging. - `python3 tools/caps.py` prints isolation, actual WebGPU adapter info and the GL renderer for Chromium and installed Firefox. Real mode requires a hardware Chromium adapter. -- `test/t_caps.py` checks missing, null and rejected WebGPU adapters, both title notices, warning - mode and the future `REQUIRE_GPU_AND_THREADS` startup gate. Its forced notice screenshot is - `shots/t88-p1-notice.png`. +- `test/t_caps.py` checks absent, null, rejected and insufficient-limit adapters, notices and + the mandatory WebGPU startup gate. Threads remain optional until Phase 3. Firefox smoke + checks that a browser without WebGPU shows the notice and cannot start a world. - Install Firefox with `.venv/bin/python -m playwright install firefox`. - `python3 test/firefox_smoke.py` checks isolation and the WebGPU notice, starts a pixel world, - runs three seconds and checks page errors. `run_all.py --browser` includes it and prints a + `python3 test/firefox_smoke.py` checks isolation and the WebGPU notice, starts a pixel world + only when WebGPU is available, and otherwise verifies the startup block. `run_all.py --browser` includes it and prints a skip note if Firefox is absent; an installed browser that fails to launch is a failure. - Linux WebGPU currently needs Firefox Nightly; the notice points to `about:config` → `dom.webgpu.enabled`. Sources: [Mozilla's experimental features](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Experimental_features#webgpu_api) @@ -245,3 +257,11 @@ The screenshot tool invalidates the shared parallax horizon after scene setup ch so renderer startup timing cannot change the hills in a comparison. Mip pass references: [Chrome's porting guide](https://developer.chrome.com/docs/web-platform/webgpu/from-webgl-to-webgpu#mipmap_generation) and [WebGPU Fundamentals](https://webgpufundamentals.org/webgpu/lessons/webgpu-importing-textures.html#generating-mips-on-the-gpu). + +The required pipeline limits are checked in `gpuLimitsOK` before requesting the device: four +storage textures, sixteen sampled textures and eight storage buffers per stage, plus 8192 for +2D textures; no non-default limit is requested. References: [WebGPU limits](https://www.w3.org/TR/2026/CRD-webgpu-20260109/#limits) +and [Chromium Dawn defaults](https://dawn.googlesource.com/dawn.git/+/refs/heads/chromium/7945/src/dawn/native/Limits.cpp). +All game pipelines use async creation during capability checking. World loading submits both +looks before `GAME.running` becomes true. Shader and loading timings are printed by `t_gpu.py`; +look/zoom switches must not call synchronous pipeline creation. diff --git a/docs/agent-log.md b/docs/agent-log.md index ae3036c..b018ccf 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,59 @@ 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 2d, required WebGPU and dirty uploads (Codex) + +- Per-32x32-chunk `renderDirty` marks cell writes, heat with its conduction halo, stationary + fire life, fog, drill damage, tree growth, eviction and block edits. `gpu/uploads.js` merges + horizontal dirty chunks into strips; clean resident chunks survive scrolling. GPU byte + readback matches the full CPU data after 240 busy ticks; an unchanged frame uploads no world cells. +- Both looks stay within default WebGPU limits: four storage textures, sixteen sampled textures, + eight storage buffers per shader stage, 8192 texture dimension; timestamp queries optional. + Smooth colour/geometry and glow use two passes. All pipelines compile asynchronously at startup; + both looks submit behind the loading screen. No fallback: missing/insufficient adapters cannot + start. Loss tries one device recreation; failure stops frames, autosaves, offers Reload (or + Export if saving fails). Firefox 157 on Linux shows the required-WebGPU notice. +- Removed `render/light.js`, `smooth.js`, `smooth-shaders.js`, `slope-belt-shader.js`, the CPU + plain-colour loop, renderer setting/tool switch and Canvas2D `drawCracks`. The rare CPU shade + classifier remains in `cell.js`; no CPU light sweep. Drill contours now use GPU colour shading + from `digp` and the same three seeded Perlin fields at screen resolution. +- Before removal captured 28 legacy reference crops (renderer, joins, sky, lamp/opening/magma/fire; + both looks, zoom 12/2), 384x288 PNGs under `test/fixtures/golden/` (1.7 MB total). All pass: + mean RGB error 0.012-0.287/255; <=0.15% pixels over 8. Golden test allows mean <0.5 and <0.3% + pixels over 8 for smooth AO rounding. Both contact sheets and pixel/smooth drill screenshots + inspected. The zoom-12 joins crop was recentered on actual geometry using the original renderer + built in a temporary checkout; the four joins crops were recaptured there. +- Final full matrix (1920x1080, RTX 5070), drawWorld zoom 2/6 (ms; baseline -> final): + settled pixel 21.78/2.71 -> 2.64/0.44, smooth 25.64/3.08 -> 5.16/0.71; + factory pixel 24.55/2.89 -> 2.53/0.47, smooth 28.20/3.34 -> 3.93/0.85; + heat pixel 24.35/3.41 -> 2.19/0.34, smooth 27.43/3.54 -> 3.51/0.85. + Factory uploads zoom 2/6: pixel 6.55/1.76 -> 0.53/0.09 ms, smooth 8.05/1.92 -> 1.88/0.44 ms + (fresh before/after runs). Real-loop CPU p50/p95 zoom 2/6: pixel 9.28/10.56 and 6.63/7.74 ms; + smooth 10.60/12.03 and 6.66/7.65 ms. Zoom 2 CPU p50 baseline pixel/smooth 31.70/34.71 ms. + Standalone sim is a remaining regression vs the old baseline: factory 3.66 -> 4.20 ms/tick, + heat 4.94 -> 5.65; settled 3.71 -> 3.49. No simulation behaviour changed (output diff exact). + Full default matrix and comparison: `/tmp/t88-final.json`, `/tmp/t88-final.log` (222.5 s). +- Isolated 960x600 warm-up check: async pipeline compilation 125.55 ms at startup; world warm-up + 23.73 ms while loading; first visible frame 4.87 ms, following frames 2.10-12.47 ms (including + normal scheduling/GC variation). Look/zoom timings are in `/tmp/t88-gpu-final.log`; no pipeline + creation occurs on switches. First switch to smooth at zoom 12: 3.42 ms (normal max 3.51); + first zoom 2 smooth: 6.01 ms (1.85 ms above the normal max of 4.16); zoom 6 smooth + 3.74 ms (normal max 3.38). All below a 16.67 ms frame budget; no first-use shader compilation. + These timings include waiting for submitted GPU work. +- Files: `sim/{world,cells,heat,fog,drill,tools,nature,machines,blocks,actions,conveyors}.js`, + `render/{world,cell,setup}.js`, `render/gpu/{uploads,cracks,surface,colour,device,draw,smooth,textures}.js`, + `ui/{title,session,claims,state,settings,loop,perf,preview-drill}.js`, manifest; browser tests, + `test/{light-cases,light-readback,t_render_dirty}.js`, golden fixtures; profiler/shot tools, + `tools/browser.py`, README, TESTING, PROJECT_MAP, t88 record. Old `test/t_light.js` moved to actual GPU checks, + `test/gpu-readback.js` retained for the play test. DESIGN and other tasks untouched. +- Verification: final `python3 test/run_all.py --browser` exited 0, ALL PASSED (361 play, + 28 smooth, 18 console, 6 capabilities, 12 GPU, 28 golden, 71 light, 2 Firefox checks; + all headless suites, including nine render-dirty checks). Retained headless outputs match + before/after exactly (timing lines ignored). `tools/test_perf.py`, map generation, diff check + and kandown doctor passed. Early gate caught premature GAME.running during warm-up; fixed by + warming before enterGame. No commit. Phase 2 implementation complete; Phase 3 next. Owner + visual review pending; other GPU hardware and Firefox with an actual adapter not tested. + ## 2026-10-27: t88 phase 2c, smooth look on WebGPU (Codex + Claude Code) - WGSL port of the smooth look (`src/render/gpu/smooth.js`, `smooth-shader.js`, `smooth-slope.js`): diff --git a/src/manifest.json b/src/manifest.json index 253f105..39393da 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -45,26 +45,25 @@ ], "client": [ "render/setup.js", - "render/light.js", "render/slope-belts.js", "render/diagonal-screws.js", "render/cell.js", "render/objects.js", + "render/gpu/cracks.js", "render/gpu/colour.js", "render/gpu/shaders.js", "render/gpu/light.js", "render/gpu/smooth-slope.js", "render/gpu/smooth-shader.js", + "render/gpu/surface.js", "render/gpu/smooth.js", "render/gpu/device.js", "render/gpu/textures.js", + "render/gpu/uploads.js", "render/gpu/draw.js", "render/world.js", "render/machines.js", "render/overlays.js", - "render/smooth.js", - "render/slope-belt-shader.js", - "render/smooth-shaders.js", "ui/audio.js", "ui/state.js", "ui/title.js", diff --git a/src/render/cell.js b/src/render/cell.js index 8b7d845..1faaba7 100644 --- a/src/render/cell.js +++ b/src/render/cell.js @@ -1,4 +1,8 @@ -// Shared CPU colour rules, used by legacy and the rare-cell WebGPU override. +// Rare-cell shading also treats built pieces as transparent surroundings. +function builtForShade(i, x, y) { + return (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || blockP[(y >> 2) * BW + (x >> 2)] || mach[i] || part[i]; +} +// CPU colour rules for the rare-cell WebGPU overrides. 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; @@ -35,7 +39,7 @@ function cellPixel(x, y) { 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 (!sm && m !== EMPTY && !builtForShade(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]) || diff --git a/src/render/gpu/colour.js b/src/render/gpu/colour.js index 81b5e23..b7e4f76 100644 --- a/src/render/gpu/colour.js +++ b/src/render/gpu/colour.js @@ -17,7 +17,7 @@ ${smooth ? `@group(0) @binding(16) var backOut: texture_storage_2d; @group(0) @binding(22) var maskOver: texture_2d; ` : ""} -@group(0) @binding(15) var glowT: texture_storage_2d; +${smooth ? "" : "@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; @@ -94,7 +94,7 @@ fn colour(@builtin(global_invocation_id) id: vec3u) { if (${smooth ? "textureLoad(maskOver,q,0).r>0u" : "over.a>0."}) { rgb=over.rgb; ${smooth ? "cl=over.a*255.; backValue=textureLoad(backOver,q,0); treeValue=textureLoad(treeOver,q,0); fieldValue=textureLoad(fieldOver,q,0);" : ""} bloom=max(bloom,textureLoad(overrideGlow,q/2,0).rgb); } ${smooth ? "textureStore(backOut,q,backValue); textureStore(treeOut,q,treeValue); textureStore(fieldOut,q,fieldValue);" : ""} textureStore(outputT,q,vec4f(rgb,${smooth ? "cl/255." : "1"})); - if (((q.x | q.y) & 1)==0) { textureStore(glowT,q/2,vec4f(bloom,1)); } + ${smooth ? "" : "if (((q.x | q.y) & 1)==0) { textureStore(glowT,q/2,vec4f(bloom,1)); }"} }`; } const GPU_COLOUR = gpuColourShader(false); diff --git a/src/render/gpu/cracks.js b/src/render/gpu/cracks.js new file mode 100644 index 0000000..37b6373 --- /dev/null +++ b/src/render/gpu/cracks.js @@ -0,0 +1,79 @@ +// The drill's three seeded Perlin contour fields, uploaded once with the material tables. +function gpuCrackTables() { + const out = new Float32Array(3 * 512 * 4); + for (let l=0;l<3;l++) { + const rng=mulberry32(0xc7ac1+l), p=Array.from({length:256},(_,i)=>i); + for(let i=255;i>0;i--) { const j=Math.floor(rng()*(i+1)); [p[i],p[j]]=[p[j],p[i]]; } + for(let i=0;i<512;i++) out[(l*512+i)*4]=p[i&255]; + for(let i=0;i<256;i++) { const a=rng()*Math.PI*2; out[(l*512+i)*4+1]=Math.cos(a); out[(l*512+i)*4+2]=Math.sin(a); } + } + return out; +} +const GPU_CRACKS = ` +fn crackNoise(p: vec2f, l: u32) -> f32 { + let base=${NMAT*3+700+551*3}u+l*512u; + let v=vec2u(vec2i(floor(p)) & vec2i(255)); let f=fract(p); + let p0=u32(lut[base+v.y].x); let p1=u32(lut[base+v.y+1u].x); + let a=lut[base+u32(lut[base+v.x+p0].x)].yz; + let b=lut[base+u32(lut[base+v.x+1u+p0].x)].yz; + let c=lut[base+u32(lut[base+v.x+p1].x)].yz; + let d=lut[base+u32(lut[base+v.x+1u+p1].x)].yz; + let t=f*f*f*(f*(f*6.-15.)+10.); + return mix(mix(dot(a,f),dot(b,f-vec2f(1,0)),t.x),mix(dot(c,f-vec2f(0,1)),dot(d,f-vec2f(1)),t.x),t.y)*1.41; +} +`; +// Screen-resolution colour output preserves the old sub-grain marching-square strokes. +const GPU_CRACK_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 matT:texture_2d; +@group(0) @binding(3) var digpT:texture_2d; +@group(0) @binding(4) var outputT:texture_storage_2d; +${GPU_CRACKS} +fn segmentDistance(p:vec2f,a:vec2f,b:vec2f)->f32 { + let d=b-a;return length(p-a-d*clamp(dot(p-a,d)/max(0.000001,dot(d,d)),0.,1.)); +} +@compute @workgroup_size(8,8) +fn main(@builtin(global_invocation_id) id:vec3u) { + let q=vec2i(id.xy);if(any(q>=vec2i(u.screen.xy))){return;} + let pos=u.extra.xy+(vec2f(q)+0.5)/u.camera.z; let p=vec2i(floor(pos)); + var colour=vec4f(0); + if(u.camera.z>=2.5 && all(p>=vec2i(0)) && all(p(0.1,0.42,0.72)[l]){continue;} + let scale=array(1./7.,1./3.6,1./2.)[l]; + let at=vec2f(p)*scale+vec2f(0.37,0.61); + let a=crackNoise(at,l);let b=crackNoise(at+vec2f(scale,0),l); + let c=crackNoise(at+vec2f(scale),l);let d=crackNoise(at+vec2f(0,scale),l); + var pts:array;var n=0u; + if((a<0.)!=(b<0.)){pts[n]=vec2f(a/(a-b),0);n++;} + if((b<0.)!=(c<0.)){pts[n]=vec2f(1,b/(b-c));n++;} + if((d<0.)!=(c<0.)){pts[n]=vec2f(d/(d-c),1);n++;} + if((a<0.)!=(d<0.)){pts[n]=vec2f(0,a/(a-d));n++;} + let width=array(0.2,0.14,0.09)[l]; + for(var stroke=0u;stroke<2u;stroke++) { + let lip=stroke==0u;let offset=select(0.,max(1./u.camera.z,0.12),lip); + var distance=100.; + for(var k=0u;k+1u; +@vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4f { + let p=array(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3));return vec4f(p[i],0,1); +} +@fragment fn fs(@builtin(position) p:vec4f)->@location(0) vec4f {return textureLoad(cracks,vec2i(p.xy),0);} +`; diff --git a/src/render/gpu/device.js b/src/render/gpu/device.js index 23c0cdd..687c05d 100644 --- a/src/render/gpu/device.js +++ b/src/render/gpu/device.js @@ -1,23 +1,30 @@ -// Async startup shares the title screen's adapter; failures leave a usable legacy renderer. +// All pipelines compile during startup; rendering never falls back. +const GPU_LIMITS = { maxStorageTexturesPerShaderStage: 4, maxSampledTexturesPerShaderStage: 16, maxStorageBuffersPerShaderStage: 8, maxTextureDimension2D: 8192 }; +function gpuLimitsOK(adapter) { return Object.entries(GPU_LIMITS).every(([k,v]) => adapter.limits[k] >= v); } const GPU = { ok: false, canvas: document.getElementById('gpu'), error: '', gpuMs: null }; async function gpuInit(adapter) { + const started = performance.now(); try { + if (!gpuLimitsOK(adapter)) throw Error("Graphics adapter does not meet the required WebGPU limits."); const features = adapter.features.has('timestamp-query') ? ['timestamp-query'] : []; - const d = GPU.device = await adapter.requestDevice({ requiredFeatures: features, requiredLimits: { maxStorageTexturesPerShaderStage: 5 } }); + const d = GPU.device = await adapter.requestDevice({ requiredFeatures: features, requiredLimits: GPU_LIMITS }); d.addEventListener('uncapturederror', e => gpuFail(e.error.message)); - d.lost.then(info => gpuFail(info.message || 'Graphics device lost')); + d.lost.then(info => { if (GPU.device === d) gpuRecover(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.colourSmooth = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR_SMOOTH }), entryPoint: 'colour' } }); - gpuSmoothInit(); + await gpuSmoothInit(); for (const [name, code, entryPoint] of [['lightRoof',GPU_LIGHT,'roof'],['lightSources',GPU_LIGHT,'sources'],['lightSpread',GPU_LIGHT_SPREAD,'main'],['lightPack',GPU_LIGHT_PACK,'main']]) { GPU[name] = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code}),entryPoint}}); } 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' } }); + GPU.cracks = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code:GPU_CRACK_COLOUR}),entryPoint:'main'}}); + const cracks = d.createShaderModule({code:GPU_CRACK_DRAW}); + GPU.crackDraw = await d.createRenderPipelineAsync({layout:'auto',vertex:{module:cracks,entryPoint:'vs'},fragment:{module:cracks,entryPoint:'fs',targets:[{format:GPU.format,blend:{color:{srcFactor:'one',dstFactor:'one-minus-src-alpha'},alpha:{srcFactor:'one',dstFactor:'one-minus-src-alpha'}}}]},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); @@ -27,14 +34,48 @@ async function gpuInit(adapter) { GPU.resolve = d.createBuffer({ size: 256, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC }); GPU.readback = d.createBuffer({ size: 32, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); } - GPU.ok = true; - } catch (e) { gpuFail(e.message); } + GPU.ok = true; GPU.error = ''; GPU.initMs = performance.now() - started; + return true; + } catch (e) { GPU.ok = false; GPU.error = e.message; return false; } } function gpuFail(message) { - GPU.ok = false; GPU.error = message; SET.renderer = 'legacy'; - GPU.canvas.hidden = true; + GPU.ok = false; GPU.error = message; console.warn('WebGPU renderer: ' + message); - if (GAME.running) toast('WebGPU stopped drawing. Legacy renderer is on.'); + if (GAME.running) gpuRecover(message); +} +async function gpuRecover(message) { + if (GPU.recovering || GPU.stopped) return; + GPU.ok = false; GPU.recovering = true; + const saved = !GAME.running || await saveWorld('auto', true); + const old = GPU.device; + // Resources belong to the lost device; force every retained cache to rebuild. + GPU.world = GPU.sky = GPU.viewKey = GPU.lightWorld = GPU.lightKey = null; + GPU.bindColour = GPU.lightDirections = GPU.queries = null; + GPU.timingBusy = false; GPU.lightFrame = undefined; + let restored = false; + try { + const adapter = navigator.gpu && await navigator.gpu.requestAdapter(); + if (adapter) restored = await gpuInit(adapter); + } catch (e) { GPU.error = e.message; } + if (restored) { if (old) old.destroy(); GPU.recovering = false; lastT = performance.now(); acc = 0; return; } + GPU.device = null; if (old) old.destroy(); + GPU.stopped = true; GPU.recovering = false; GAME.paused = true; + dialog('Graphics stopped', '

The graphics device could not restart. '+ + (saved ? 'Your world was autosaved. Reload to continue.' : 'Saving failed. Export your world before reloading.')+'

', [ + ...(!saved ? [{label:'Export world',fn:exportWorld}] : []), + {label:'Reload',fn:()=>location.reload()} + ]); +} +// Submit both looks behind the loading screen, including their first real bindings. +async function gpuWarmWorld() { + buildSky(); resizeRender(); snapCamera(); + const look = SET.look, started = performance.now(); + try { + for (const next of [look === 'smooth' ? 'pixel' : 'smooth', look]) { + SET.look = next; drawWorld(); await GPU.device.queue.onSubmittedWorkDone(); + } + GPU.warmMs = performance.now() - started; + } finally { SET.look = look; } } // Tests sample this composite so a transparent overlay cannot pass world-colour checks. function worldPixelContext() { diff --git a/src/render/gpu/draw.js b/src/render/gpu/draw.js index fb3832c..3c17774 100644 --- a/src/render/gpu/draw.js +++ b/src/render/gpu/draw.js @@ -48,8 +48,14 @@ function gpuDraw(lighting, glowOn, lightUpdate) { 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(R.smoothOn ? GPU.colourSmooth : GPU.colour); compute.setBindGroup(0, R.smoothOn ? GPU.bindColourSmooth : GPU.bindColour); - compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8)); compute.end(); + compute.setPipeline(GPU.colour); compute.setBindGroup(0, GPU.bindColour); + if (R.smoothOn) { + compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8)); + compute.setPipeline(GPU.colourSmooth); compute.setBindGroup(0, GPU.bindColourSmooth); + } + compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8)); + compute.setPipeline(GPU.cracks); compute.setBindGroup(0, GPU.bindCracks); + compute.dispatchWorkgroups(Math.ceil(R.canvas.width / 8), Math.ceil(R.canvas.height / 8)); compute.end(); if (glowOn && !R.smoothOn) 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(); @@ -78,6 +84,9 @@ function gpuDraw(lighting, glowOn, lightUpdate) { // 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(R.smoothOn ? GPU.smooth : GPU.draw); pass.setBindGroup(0, R.smoothOn ? GPU.bindSmooth : GPU.bindDraw); pass.draw(3); pass.end(); + const cracks = encoder.beginRenderPass({colorAttachments:[{view:GPU.context.getCurrentTexture().createView(),loadOp:'load',storeOp:'store'}], + ...(timed ? {timestampWrites:{querySet:GPU.queries,endOfPassWriteIndex:1}} : {})}); + cracks.setPipeline(GPU.crackDraw);cracks.setBindGroup(0,GPU.bindCrackDraw);cracks.draw(3);cracks.end(); } diff --git a/src/render/gpu/smooth.js b/src/render/gpu/smooth.js index ea7894e..3402c73 100644 --- a/src/render/gpu/smooth.js +++ b/src/render/gpu/smooth.js @@ -9,11 +9,11 @@ const GPU_FIELD_MIP = ` let size=max(vec2u(1),textureDimensions(source)/2u); return textureSampleLevel(source,linear,p.xy/vec2f(size),0); }`; -function gpuSmoothInit() { +async function gpuSmoothInit() { const d=GPU.device; - const pipeline=(code,vertex,format)=>d.createRenderPipeline({layout:'auto',vertex:{module:d.createShaderModule({code:vertex}),entryPoint:'vs'},fragment:{module:d.createShaderModule({code}),entryPoint:'fs',targets:[{format}]},primitive:{topology:'triangle-list'}}); - GPU.smooth=pipeline(GPU_SMOOTH,GPU_DRAW,GPU.format); - GPU.fieldMip=pipeline(GPU_FIELD_MIP,GPU_FIELD_MIP,'rgba8unorm'); + const pipeline=(code,vertex,format)=>d.createRenderPipelineAsync({layout:'auto',vertex:{module:d.createShaderModule({code:vertex}),entryPoint:'vs'},fragment:{module:d.createShaderModule({code}),entryPoint:'fs',targets:[{format}]},primitive:{topology:'triangle-list'}}); + GPU.smooth=await pipeline(GPU_SMOOTH,GPU_DRAW,GPU.format); + GPU.fieldMip=await pipeline(GPU_FIELD_MIP,GPU_FIELD_MIP,'rgba8unorm'); GPU.smoothUniform=d.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST}); } function gpuSmoothTextures() { @@ -26,7 +26,8 @@ function gpuSmoothTextures() { GPU.viewTextures.push(...GPU.smoothOver,GPU.smoothMask,GPU.backTex,GPU.treeTex,GPU.fieldTex); } function gpuSmoothBindings(group,view,buffer) { - GPU.bindColourSmooth=group(GPU.colourSmooth,[buffer(GPU.uniform),buffer(GPU.lut),buffer(GPU.land),...GPU.worldTextures.slice(0,9).map(view),view(GPU.override),view(GPU.overrideGlow),view(GPU.colourTex),view(GPU.glowTex),view(GPU.backTex),view(GPU.treeTex),GPU.fieldTex.createView({baseMipLevel:0,mipLevelCount:1}),...GPU.smoothOver.map(view),view(GPU.smoothMask)]); + const colourResources=[buffer(GPU.uniform),buffer(GPU.lut),buffer(GPU.land),...GPU.worldTextures.slice(0,9).map(view),view(GPU.override),view(GPU.overrideGlow),view(GPU.colourTex),null,view(GPU.backTex),view(GPU.treeTex),GPU.fieldTex.createView({baseMipLevel:0,mipLevelCount:1}),...GPU.smoothOver.map(view),view(GPU.smoothMask)]; + GPU.bindColourSmooth=GPU.device.createBindGroup({layout:GPU.colourSmooth.getBindGroupLayout(0),entries:colourResources.map((resource,binding)=>({binding,resource})).filter(e=>e.resource)}); GPU.bindSmooth=group(GPU.smooth,[buffer(GPU.uniform),view(GPU.colourTex),view(GPU.backTex),view(GPU.treeTex),view(GPU.fieldTex),view(GPU.lightTex),view(GPU.glowTex),GPU.sampler,buffer(GPU.smoothUniform)]); GPU.fieldMips=[]; for(let i=1;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); + const tables = new Float32Array((NMAT * 3 + 700 + 551 * 3 + 3 * 512) * 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); + tables.set(gpuCrackTables(), (NMAT * 3 + 700 + 551 * 3) * 4); GPU.lut = gpuBuffer(tables); GPU.landData = new Float32Array((W + H) * 4); GPU.land = gpuBuffer(GPU.landData); @@ -35,18 +37,19 @@ function gpuWorldTextures() { GPU.bindColour = null; } function gpuViewTextures() { - const key = `${R.bw}/${R.bh}/${R.lw}/${R.lh}/${+R.smoothOn}`; + const key = `${R.bw}/${R.bh}/${R.lw}/${R.lh}/${+R.smoothOn}/${R.canvas.width}/${R.canvas.height}`; if (GPU.viewKey === key) return; GPU.viewKey = key; GPU.lightFrame = undefined; 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.crackTex = gpuTexture(R.canvas.width, R.canvas.height, 'rgba8unorm', true); 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), 'rgba8unorm', true); GPU.lightWork = [0,1].map(() => gpuTexture(Math.max(1,R.lw),Math.max(1,R.lh),'rgba16float',true)); - GPU.viewTextures = [GPU.override, GPU.overrideGlow, GPU.colourTex, GPU.glowTex, GPU.blurX, GPU.blurY, GPU.lightTex, ...GPU.lightWork]; + GPU.viewTextures = [GPU.crackTex, GPU.override, GPU.overrideGlow, GPU.colourTex, GPU.glowTex, GPU.blurX, GPU.blurY, GPU.lightTex, ...GPU.lightWork]; if (R.smoothOn) gpuSmoothTextures(); GPU.bindColour = null; } @@ -57,6 +60,8 @@ function gpuBindings() { GPU.bindColour = group(GPU.colour, [buffer(GPU.uniform), buffer(GPU.lut), buffer(GPU.land), ...GPU.worldTextures.slice(0,9).map(view), view(GPU.override), view(GPU.overrideGlow), view(GPU.colourTex), view(GPU.glowTex)]); if (R.smoothOn) gpuSmoothBindings(group, view, buffer); gpuLightBindings(group, view, buffer); + GPU.bindCracks = group(GPU.cracks, [buffer(GPU.uniform),buffer(GPU.lut),view(GPU.worldTextures[0]),view(GPU.worldTextures[5]),view(GPU.crackTex)]); + GPU.bindCrackDraw = group(GPU.crackDraw, [view(GPU.crackTex)]); 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 })); @@ -67,20 +72,9 @@ function gpuUpload(lighting, glowOn) { const margin = lighting ? Math.ceil(28 / R.lres) * R.lres + R.lres : 2; const x = Math.max(0, R.vx0 - margin), y = Math.max(0, R.vy0 - margin); const w = Math.min(W, R.vx0 + R.bw + margin) - x, h = Math.min(H, R.vy0 + R.bh + margin + 12) - 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); - if (lighting) { - let top=y; - for(let xx=x;xx>2, by=top>>2; - gpuWrite(GPU.block,blockP,BW,bx,by,Math.min(BW,Math.ceil((x+w)/4))-bx,Math.min(BH,Math.ceil((y+h)/4))-by); - } - 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); + let top = y; + if (lighting) for (let xx = x; xx < x + w; xx++) top = Math.min(top, roofY[xx]); + gpuUploadChunks(x, top, x + w, y + h); // 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]; } diff --git a/src/render/gpu/uploads.js b/src/render/gpu/uploads.js new file mode 100644 index 0000000..672ee8e --- /dev/null +++ b/src/render/gpu/uploads.js @@ -0,0 +1,48 @@ +// Upload full chunks so scrolling within a resident chunk needs no extra copy. +function gpuUploadChunks(x0, y0, x1, y1) { + const cx0 = Math.max(0, x0 >> 5), cy0 = Math.max(0, y0 >> 5); + const cx1 = Math.min(CW, Math.ceil(x1 / 32)), cy1 = Math.min(CH, Math.ceil(y1 / 32)); + GPU.uploadStrips = 0; GPU.uploadCells = 0; + for (let cy = cy0; cy < cy1; cy++) for (let cx = cx0; cx < cx1;) { + const c = cy * CW + cx; + if (!renderDirty[c] && GPU.resident[c]) { cx++; continue; } + const first = cx++; + while (cx < cx1 && (renderDirty[cy * CW + cx] || !GPU.resident[cy * CW + cx])) cx++; + const x = first * 32, y = cy * 32, w = Math.min(W, cx * 32) - x, h = Math.min(32, H - y); + gpuUploadStrip(x, y, w, h); + GPU.uploadStrips++; GPU.uploadCells += w * h; + for (let k = first; k < cx; k++) { renderDirty[cy * CW + k] = 0; GPU.resident[cy * CW + k] = 1; } + } +} +function gpuUploadStrip(x, y, w, h) { + for (const [i, a] of [mat, flags, life, part, bgm, digp].entries()) gpuWrite(GPU.worldTextures[i], a, W, x, y, w, h); + gpuWrite(GPU.temp, temp, W, x, y, w, h); + gpuWrite(GPU.mach, mach, W, x, y, w, h); + gpuWrite(GPU.block, blockP, BW, x >> 2, y >> 2, Math.ceil(w / BS), Math.ceil(h / BS)); + // Fog interpolation reads the next sample, possibly in a neighbouring chunk. + gpuWrite(GPU.worldTextures[7], fog, FW, x / FOG, y / FOG, Math.min(FW - x / FOG, Math.ceil(w / FOG) + 1), Math.min(FH - y / FOG, Math.ceil(h / FOG) + 1)); +} +// Debug-only readback: compares resident GPU texels to CPU arrays without refreshing them. +async function gpuCheckUploads() { + const sources = [...[mat, flags, life, part, bgm, digp].map((a,i)=>[GPU.worldTextures[i],a,W,1]), + [GPU.temp,temp,W,1], [GPU.mach,mach,W,1], [GPU.block,blockP,BW,BS], [GPU.worldTextures[7],fog,FW,FOG]]; + let checked = 0; + for (const [texture, data, stride, scale] of sources) { + const bytes = data.BYTES_PER_ELEMENT, row = Math.ceil(texture.width * bytes / 256) * 256; + const buffer = GPU.device.createBuffer({size:row*texture.height,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ}); + const encoder = GPU.device.createCommandEncoder(); + encoder.copyTextureToBuffer({texture},{buffer,bytesPerRow:row},[texture.width,texture.height]); + GPU.device.queue.submit([encoder.finish()]); await buffer.mapAsync(GPUMapMode.READ); + const actual = new Uint8Array(buffer.getMappedRange()), expected = new Uint8Array(data.buffer,data.byteOffset,data.byteLength); + let mismatch = ''; + for (let cy=0;cy> 2) * BW + (x >> 2)] || mach[i] || part[i]; -} -function looseForLight(i) { - for (let depth = 0; depth <= 12 && i < N; depth++, i += W) { - const m = mat[i], ph = PH[m]; - if (builtForLight(i, i % W, (i / W) | 0)) return true; - if (ph !== PH_POWDER && ph !== PH_LIQUID) return m === EMPTY || ph === PH_GAS; - } - return false; -} -function computeLight() { - const res = SET.lighting === 'fast' ? 4 : 2; - const M = Math.ceil(28 / res); - const lx0 = Math.floor(R.vx0 / res) - M, ly0 = Math.floor(R.vy0 / res) - M; - const lw = Math.ceil(R.bw / res) + 2 * M, lh = Math.ceil(R.bh / res) + 2 * M; - if (lw !== R.lw || lh !== R.lh || res !== R.lres || !R.lr || R.lr.length !== lw * lh || R.lightC.width !== lw || R.lightC.height !== lh) { - R.lw = lw; R.lh = lh; R.lres = res; - R.lr = new Float32Array(lw * lh); R.lg = new Float32Array(lw * lh); R.lb = new Float32Array(lw * lh); R.latt = new Float32Array(lw * lh); - R.lightC.width = lw; R.lightC.height = lh; - R.lightImg = R.lightX.createImageData(lw, lh); - R.lightPx = new Uint32Array(R.lightImg.data.buffer); - R.lightRoof = new Int32Array(lw); - } - R.lx0 = lx0; R.ly0 = ly0; - const lr = R.lr, lg = R.lg, lb = R.lb, la = R.latt; - // per-grain transmittance: air .978, gas .96, liquid .93, solid .935 (light soaks a little way into rock) - const P = res; - const aAir = Math.pow(0.978, P), aGas = Math.pow(0.96, P), aLiq = Math.pow(0.93, P), aSol = Math.pow(0.9, P); - const skyK = SET.daylight * (1 - 0.32 * (R.rain || 0)); // overcast while it rains - // Only sampled view columns, starting at the simulation roof; nothing below the view matters. - const roofEnd = Math.min(H, (ly0 + lh) * res), lightRoof = R.lightRoof; - for (let lx = 0; lx < lw; lx++) { - const x = (lx0 + lx) * res; - if (x < 0 || x >= W) continue; - let y = roofY[x]; - for (; y < roofEnd; y++) { - const i = y * W + x, ph = PH[mat[i]]; - if ((ph === PH_SOLID || ph === PH_POWDER || ph === PH_LIQUID) && !builtForLight(i, x, y) && !((ph === PH_POWDER || ph === PH_LIQUID) && looseForLight(i))) break; - } - lightRoof[lx] = y; - } - for (let ly = 0; ly < lh; ly++) { - const wy = (ly0 + ly) * res; - for (let lx = 0; lx < lw; lx++) { - const wx = (lx0 + lx) * res, k = ly * lw + lx; - if (wx < 0 || wy < 0 || wx >= W || wy >= H) { la[k] = 0; lr[k] = lg[k] = lb[k] = 0; continue; } - const i = wy * W + wx, m = mat[i], ph = PH[m]; - la[k] = builtForLight(i, wx, wy) || ((ph === PH_POWDER || ph === PH_LIQUID) && looseForLight(i)) ? aAir : ph === PH_EMPTY ? (bgm[i] ? aGas : aAir) : ph === PH_GAS ? aGas : ph === PH_LIQUID ? aLiq : m === GLASS || m === ICE ? aGas : aSol; - let r = 0, g = 0, b = 0; - if (wy < lightRoof[lx]) { r = skyK; g = skyK * 0.98; b = skyK * 0.94; } - else if (wy < surfY[wx] && ph !== PH_SOLID) { const v = skyK * 0.9; r = v; g = v * 0.98; b = v * 0.95; } - // emission from heat: hottest grain in the block - let T = 0; - const xe = Math.min(W, wx + res), ye = Math.min(H, wy + res); - for (let yy = wy; yy < ye; yy++) { const row = yy * W; for (let xx = wx; xx < xe; xx++) { const tt = temp[row + xx]; if (tt > T) T = tt; } } - if (T > 520) { - const gi = glowIndex(T), e = GLOW_W[gi] * 1.35; - r += GLOW_R[gi] / 255 * e; g += GLOW_G[gi] / 255 * e; b += GLOW_B[gi] / 255 * e; - } - lr[k] = r; lg[k] = g; lb[k] = b; - } - } - // bot lamp - if (bot.lamp) { - const cx = (bot.x + bot.w / 2) / res - lx0, cy = (bot.y + bot.h / 2) / res - ly0, rad = 34 / res; - for (let ly = Math.max(0, Math.floor(cy - rad)); ly < Math.min(lh, cy + rad); ly++) - for (let lx = Math.max(0, Math.floor(cx - rad)); lx < Math.min(lw, cx + rad); lx++) { - const d = Math.hypot(lx - cx, ly - cy) / rad; - if (d >= 1) continue; - const v = (1 - d) * (1 - d) * 1.05, k = ly * lw + lx; - if (lr[k] < v) lr[k] = v; if (lg[k] < v * 0.93) lg[k] = v * 0.93; if (lb[k] < v * 0.8) lb[k] = v * 0.8; - } - } - // propagate: forward sweep (from left/up) then backward sweep (from right/down) - for (let ly = 0; ly < lh; ly++) for (let lx = 0; lx < lw; lx++) { - const k = ly * lw + lx, a = la[k]; - let r = lr[k], g = lg[k], b = lb[k]; - if (lx > 0) { const j = k - 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; } - if (ly > 0) { const j = k - lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; } - lr[k] = r; lg[k] = g; lb[k] = b; - } - for (let ly = lh - 1; ly >= 0; ly--) for (let lx = lw - 1; lx >= 0; lx--) { - const k = ly * lw + lx, a = la[k]; - let r = lr[k], g = lg[k], b = lb[k]; - if (lx < lw - 1) { const j = k + 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; } - if (ly < lh - 1) { const j = k + lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; } - lr[k] = r; lg[k] = g; lb[k] = b; - } - // write the light canvas - const fl = SET.ambient, px = R.lightPx, n = lw * lh; - for (let k = 0; k < n; k++) { - let r = lr[k], g = lg[k], b = lb[k]; - if (r < fl) r = fl; if (g < fl) g = fl; if (b < fl) b = fl; - 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.gpuOn) R.lightX.putImageData(R.lightImg, 0, 0); -} diff --git a/src/render/setup.js b/src/render/setup.js index ae2ee13..4b2af56 100644 --- a/src/render/setup.js +++ b/src/render/setup.js @@ -5,7 +5,7 @@ const R = { canvas: null, ctx: null, buf: null, bctx: null, img: null, px: null, glowC: null, glowX: null, glowImg: null, glowPx: null, bw: 0, bh: 0, zp: 3, camX: 0, camY: 0, vx0: 0, vy0: 0, - lr: null, lg: null, lb: null, latt: null, lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1, + lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1, frame: 0, lens: false, particles: [], }; @@ -143,8 +143,6 @@ function initRender(canvas) { R.bctx = R.buf.getContext('2d'); R.glowC = document.createElement('canvas'); R.glowX = R.glowC.getContext('2d'); - R.lightC = document.createElement('canvas'); - R.lightX = R.lightC.getContext('2d'); } function resizeRender() { diff --git a/src/render/slope-belt-shader.js b/src/render/slope-belt-shader.js deleted file mode 100644 index 09dac11..0000000 --- a/src/render/slope-belt-shader.js +++ /dev/null @@ -1,111 +0,0 @@ -// Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js. -const SM_SLOPE_HELPERS = ` -// The same rounded strokes as slopeBeltShape; y/z carry distance along/across the band. -vec3 slopeStroke(vec2 p, vec2 a, vec2 b, float origin) { - vec2 ray = b - a; float len = length(ray); ray /= len; - float along = dot(p - a, ray), across = dot(p - a, vec2(-ray.y, ray.x)); - vec2 q = abs(vec2(along - len * 0.5, across)) - vec2(len * 0.5 - 0.75, 1.1); - float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - 0.75; - return vec3(sd, origin + along, across + 2.0); -} -vec3 slopeCorner(vec2 p, vec2 a, float origin) { - vec2 d = p - a; float radius = length(d); - return vec3(radius - 1.85, origin + d.x, 2.0 + d.y); -} -vec3 slopeShape(vec2 p, vec2 worldOrigin, float grade, int joins) { - if ((joins & 64) != 0) p.y -= 0.45; - float sy = grade < 0.0 ? 4.0 : 0.0; - vec2 a = vec2(-grade * 1.30814755, sy + 1.30814755), b = vec2(4.0 - grade * 1.30814755, 4.0 - sy + 1.30814755); - if ((joins & 1) != 0) { a.y = sy + 2.0; a.x = (a.y - sy - 2.61629509) / grade; } - else if ((joins & 4) != 0) a -= vec2(4.0, grade * 4.0); - if ((joins & 2) != 0) { b.y = 6.0 - sy; b.x = (b.y - sy - 2.61629509) / grade; } - else if ((joins & 8) != 0) b += vec2(4.0, grade * 4.0); - vec2 axis = vec2(1.0, grade) * 0.70710678; - vec3 best = slopeStroke(p, a, b, dot(worldOrigin + a, axis)); - if ((joins & 1) != 0) { - vec3 h = slopeStroke(p, vec2(-2.0, a.y), a, worldOrigin.x - 2.0); - if (h.x < best.x) best = h; - h = slopeCorner(p, a, worldOrigin.x + a.x); - if (h.x < best.x) best = h; - } - if ((joins & 2) != 0) { - vec3 h = slopeStroke(p, b, vec2(6, b.y), worldOrigin.x + b.x); - if (h.x < best.x) best = h; - h = slopeCorner(p, b, worldOrigin.x + b.x); - if (h.x < best.x) best = h; - } - return best; -} -`; - -const SM_SLOPE_DRAW = ` - if (nc == 31 || nc == 32) { - vec4 tag = cellAt(tTree, c); - float grade = nc == 31 ? -1.0 : 1.0; - vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w); - float sy = grade < 0.0 ? 4.0 : 0.0; - float foot = (local.x + grade * (local.y - sy)) * 0.5; - float depth = (grade * local.x - local.y + sy) * 0.70710678; - int wallFlags = int(tag.a * 255.0 + 0.5); - float ends = max((wallFlags & 8) == 0 ? -foot : -100.0, (wallFlags & 16) == 0 ? foot - 4.0 : -100.0); - float sd = max(ends * 1.41421356, max(-depth, depth - ${CLEAT_HEIGHT.toFixed(1)})); - float phase = float(wallFlags & 7); - float wave = 0.5 + 0.5 * sin((foot + w.x - local.x - phase) * 3.14159265); - vec3 rubber = mix(vec3(0.20,0.22,0.25), vec3(0.25,0.27,0.31), wave); - C = mix(C, rubber, 1.0 - smoothstep(-px, px, sd)); - // the ribs, where the simulation has them: rubber bars standing straight out of an uphill belt, - // one every ${CLEAT_SPACING} face cells, moved along by the belt's phase - float dirS = (wallFlags & 32) != 0 ? 1.0 : -1.0; - if (grade * dirS < 0.0) { - float rc = (w.x - local.x) + foot - (grade < 0.0 ? 1.0 : 0.0) - dirS * phase; - float off = (mod(rc + ${(CLEAT_SPACING / 2).toFixed(1)}, ${CLEAT_SPACING.toFixed(1)}) - ${(CLEAT_SPACING / 2).toFixed(1)}) * 1.41421356; - float ribTop = ${((2 * CLEAT_REACH + 1) * Math.SQRT1_2).toFixed(3)}; - float rd = length(vec2(off, depth - clamp(depth, 0.0, ribTop))) - 0.5; - vec3 rib = mix(vec3(0.50,0.52,0.56), vec3(0.70,0.72,0.76), smoothstep(ribTop - 1.2, ribTop, depth)); - rib *= mix(0.78, 1.0, smoothstep(-0.5, 0.2, off * -dirS * grade)); - C = mix(C, rib, 1.0 - smoothstep(-px, px, rd)); - } - } - - // Slopes use the flat belt's band, rollers and mesh in coordinates along the run. - if ((nc == 2 || (nc >= 23 && nc <= 30)) && - (int(cellAt(tTree, c).a * 255.0 + 0.5) & 128) != 0 && - cellAt(tTree, c).r * 255.0 >= 22.5 && cellAt(tTree, c).r * 255.0 <= 30.5) { - int slopeClass = int(cellAt(tTree, c).r * 255.0 + 0.5); - int shape = (slopeClass - 23) / 2; - bool mesh = shape >= 2; - float grade = (shape == 0 || shape == 2) ? -1.0 : 1.0; - float dir = ((slopeClass - 23) % 2) == 1 ? 1.0 : -1.0; - vec4 tag = cellAt(tTree, c); - int joins = int(tag.a * 255.0 + 0.5); - vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w); - vec3 shapePos = slopeShape(local, w - local, grade, joins); - float sd = shapePos.x, axis = shapePos.y, depth = shapePos.z; - float time = !mesh && (joins & 16) != 0 ? uTime : 0.0; - vec2 lb = vec2(axis, depth), alongWorld = vec2(axis, depth); - float rg = step(0.72, fract((axis - dir * (depth < 2.0 ? 1.0 : -1.0) * time * 30.0) * 0.5)); - vec3 belt; - if (-sd < (mesh ? 1.1 : 0.8)) { - belt = mesh ? mix(vec3(0.22,0.25,0.29), vec3(0.48,0.53,0.59), depth < 2.0 ? 0.85 : 0.35) : bandColor(lb, rg); - belt *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), -sd)); - } else if (mesh) { - vec2 cg = abs(fract(alongWorld) - 0.5); - float bw2 = max(px * 0.9, 0.09); - float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y)); - vec3 steel = dot(own.rgb, vec3(1)) > 0.01 ? mix(vec3(0.42, 0.44, 0.5), own.rgb, 0.4) : vec3(0.42, 0.44, 0.5); - belt = mix(steel * 0.78, C * 0.8, hole); - } else { - vec2 dv = vec2(mod(axis, 2.0) - 1.0, depth - 2.0); - float rd = length(dv); - belt = vec3(0.15, 0.16, 0.18); - if (rd < 0.95) { - float ang = atan(dv.y, dv.x) - dir * time * 30.0 / 0.95; - float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0)); - belt = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd)); - belt *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd)); - belt *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd)); - } - } - C = mix(C, belt, 1.0 - smoothstep(-px, px, sd)); - } -`; diff --git a/src/render/smooth-shaders.js b/src/render/smooth-shaders.js deleted file mode 100644 index 33787c3..0000000 --- a/src/render/smooth-shaders.js +++ /dev/null @@ -1,377 +0,0 @@ -// Smooth shaders: vertex and fragment programs for the rounded world look. -const SM_VS = `#version 300 es -void main() { - vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); - gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); -}`; - -const SM_FS = `#version 300 es -precision highp float; -precision highp int; -uniform sampler2D tCol, tColL, tBack, tTree, tField, tLight, tGlow; -uniform vec2 uSize, uOrigin, uLightSize, uGlowSize; -uniform vec4 uView, uLightMap; -uniform float uGlowK, uTime, uSurf, uRain; -out vec4 o; - -float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } -float vn(vec2 p) { - vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); - return mix(mix(h21(i), h21(i + vec2(1, 0)), f.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), f.x), f.y); -} -float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int k = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; } - -vec4 lin(sampler2D t, vec2 q) { return textureLod(t, q / uSize, 0.0); } -// cubic B-spline from four bilinear taps: rounds a field of 0s and 1s into smooth shapes -vec4 bsp(sampler2D t, vec2 q) { - vec2 tc = q - 0.5, i = floor(tc), f = tc - i, f2 = f * f, f3 = f2 * f; - vec2 w0 = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0, w1 = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0; - vec2 w2 = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0, w3 = f3 / 6.0; - vec2 g0 = w0 + w1, g1 = w2 + w3; - vec2 h0 = (i - 0.5 + w1 / g0) / uSize, h1 = (i + 1.5 + w3 / g1) / uSize; - return g0.y * (g0.x * textureLod(t, h0, 0.0) + g1.x * textureLod(t, vec2(h1.x, h0.y), 0.0)) - + g1.y * (g0.x * textureLod(t, vec2(h0.x, h1.y), 0.0) + g1.x * textureLod(t, h1, 0.0)); -} -vec4 cellAt(sampler2D t, ivec2 c) { return texelFetch(t, clamp(c, ivec2(0), ivec2(uSize) - 1), 0); } -int cls(vec4 c) { return int(c.a * 255.0 + 0.5); } -float isT(int n) { return (n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ? 1.0 : 0.0; } -float isM(int n) { return n == 2 ? 1.0 : isT(n); } // matter: ground, built pieces or loose grains -float isF(int n) { return (n == 13 || n == 14) ? 1.0 : 0.0; } -float isBelt(int n) { return (n == 7 || n == 8) ? 1.0 : 0.0; } -float isN(int n, int k) { return n == k ? 1.0 : 0.0; } -// signed distance (in grains) to the outline of one block of belt: a band 3.7 grains tall; where -// the line of belts ends, the end is square with rounded corners. Negative inside. -float beltSD(vec2 lb, bool endL, bool endR) { - float x0 = endL ? 0.15 : -2.0, x1 = endR ? 3.85 : 6.0, r = 0.75; - vec2 c = vec2((x0 + x1) * 0.5, 2.0), hs = vec2((x1 - x0) * 0.5, 1.85); - vec2 q = abs(lb - c) - hs + r; - return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; -} -// The outline of the belt or filter in cell c. Belts and filters side by side are one belt, so -// the line only rounds off where the whole run ends. -float beltLineSD(ivec2 c, vec2 lb) { - int caps = int(cellAt(tTree, c).a * 255.0 + 0.5); - return beltSD(lb, (caps & 1) != 0, (caps & 2) != 0); -} -// The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and -// back along the bottom. -float bandRidge(vec2 w, vec2 lb, float dir) { - float run = lb.y < 2.0 ? 1.0 : -1.0; - return step(0.72, fract((w.x - dir * run * uTime * 30.0) * 0.5)); -} -vec3 bandColor(vec2 lb, float rg) { return mix(vec3(0.2, 0.21, 0.24), vec3(0.34, 0.35, 0.39), rg) * (lb.y < 2.0 ? 1.0 : 0.72); } - -${SM_SLOPE_HELPERS} - -void main() { - float zp = uView.z, px = 1.0 / zp; - vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y); - vec2 q = sp * px + uView.xy; // position in grains, in the window of grains uploaded - vec2 w = q + uOrigin; // position in the world - vec2 cam = uOrigin + uView.xy; // world position of the screen's top-left corner - vec2 scr = q - uView.xy; // grains from the screen's top-left corner - float shc = uView.w * px; // screen height in grains - ivec2 c = ivec2(floor(q)); - vec2 qq = q - 0.5; ivec2 c0 = ivec2(floor(qq)); vec2 t = qq - floor(qq); - vec4 k0 = cellAt(tCol, c0), k1 = cellAt(tCol, c0 + ivec2(1, 0)), k2 = cellAt(tCol, c0 + ivec2(0, 1)), k3 = cellAt(tCol, c0 + ivec2(1, 1)); - int n0 = cls(k0), n1 = cls(k1), n2 = cls(k2), n3 = cls(k3); - vec4 own = cellAt(tCol, c); int nc = cls(own); - vec2 s = smoothstep(0.1, 0.9, t); - vec4 wq = vec4((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y); - - // fields, and everything that needs screen derivatives (worked out before any branching) - vec4 Fb = bsp(tField, q); - vec4 F = mix(lin(tField, q), Fb, 0.62); - // loose-grain occupancy from the four grains around (bilinear): ground and grains together make "matter" - vec4 wl = vec4((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y); - float pw = dot(wl, vec4(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2))); - float M = clamp(F.r + pw, 0.0, 1.0); - float lf = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0); - float fwT = max(fwidth(F.r), 1e-4), fwM = max(fwidth(M), 1e-4), fwA = max(fwidth(F.a), 1e-4), fwL = max(fwidth(lf), 1e-4); - vec2 gT = vec2(dFdx(mix(Fb.r, M, 0.5)), dFdy(mix(Fb.r, M, 0.5))), gL = vec2(dFdx(F.g), dFdy(F.g)); - - // ---- behind everything: sky with clouds and far hills, or the cave's back wall - vec4 back = lin(tBack, q); - vec3 C = back.rgb; - float sky = back.a; - if (sky > 0.01) { - vec3 S = C; - vec2 P0 = scr + cam * 0.08; - // clouds; while it rains they thicken and turn grey - float cl = smoothstep(0.56 - 0.3 * uRain, 0.78 - 0.2 * uRain, fbm(vec2(P0.x * 0.016 + uTime * 0.006, P0.y * 0.05))); - float hi = clamp((uSurf - 40.0 - w.y) / 50.0, 0.0, 1.0); - S = mix(S, mix(vec3(0.96, 0.95, 0.93), vec3(0.5, 0.52, 0.56), uRain), cl * (0.6 + 0.3 * uRain) * hi); - float k1 = 0.16; vec2 P1 = scr + cam * k1; - float base1 = shc * 0.5 + 8.0 + (uSurf - shc * 0.5) * k1; - float top1 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2(P1.x * 0.009, 1.7))); - float m1 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y); - vec3 far = mix(vec3(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05)); - S = mix(S, far, m1); - float k2 = 0.38; vec2 P2 = scr + cam * k2; - float base2 = shc * 0.5 + 14.0 + (uSurf - shc * 0.5) * k2; - float top2 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2(P2.x * 0.016, 7.3))); - float m2 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y); - vec3 near = vec3(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08)); - near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y)); - S = mix(S, near, m2); - C = mix(C, S, sky); - } - // the back wall: rough rock, and darker close to the tunnel walls (ambient occlusion) - float notSky = 1.0 - sky; - C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky); - float near = textureLod(tField, q / uSize, 2.2).r; - C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky); - - // ---- trees (behind the ground and everything on it) - if (F.a > 0.2) { - vec4 a0 = cellAt(tTree, c0), a1 = cellAt(tTree, c0 + ivec2(1, 0)), a2 = cellAt(tTree, c0 + ivec2(0, 1)), a3 = cellAt(tTree, c0 + ivec2(1, 1)); - vec4 m = vec4(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq; - float sm = m.x + m.y + m.z + m.w; - if (sm > 1e-4) { - vec3 tc = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm; - float leaf = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm; - float lv = smoothstep(0.6, 0.9, leaf); - tc *= mix(0.86 + 0.22 * vn(vec2(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv); - float d = (F.a - 0.36) / fwA; - tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d)); - C = mix(C, tc, smoothstep(-0.7, 0.7, d)); - } - } - - // ---- smoke, steam and flame: soft, see-through puffs - if (Fb.b > 0.02) { - vec4 m = vec4(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq; - float sm = m.x + m.y + m.z + m.w; - vec3 gc = sm > 1e-4 ? (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm : own.rgb; - if (sm > 1e-4 || nc == 5) C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92); - } - - // ---- liquids: smooth surfaces that fill right up against the ground, with a glossy top line - float anyL = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3); - if (anyL > 0.0 && lf > 0.3) { - vec4 m = vec4(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02); - float sm = m.x + m.y + m.z + m.w; - vec3 lc = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm; - lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2(uTime * 0.7, uTime * 0.25)); - float gl = length(gL); - float topSide = gl > 1e-5 ? clamp(gL.y / gl, 0.0, 1.0) : 0.0; - float band = 1.0 - smoothstep(0.5, 0.75, F.g); - lc += vec3(0.16, 0.18, 0.2) * topSide * band; - C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf)); - } - - // ---- the ground: rounded, outlined, with a lit top edge and a shaded underside - if (F.r > 0.3) { - vec4 m = vec4(isT(n0), isT(n1), isT(n2), isT(n3)) * wq; - float sm = m.x + m.y + m.z + m.w; - vec3 blur = bsp(tColL, q).rgb, tc = own.rgb; - float bd = 1e9; - if (m.x > 0.0) { vec3 e = k0.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } } - if (m.y > 0.0) { vec3 e = k1.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } } - if (m.z > 0.0) { vec3 e = k2.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } } - if (m.w > 0.0) { vec3 e = k3.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } } - if (sm < 1e-4) tc = own.rgb; - tc *= 0.84 + 0.3 * fbm(w * 0.28); - tc *= 0.95 + 0.1 * vn(w * 1.6); - float gl = length(gT); - vec2 nrm = gl > 1e-6 ? -gT / gl : vec2(0.0); - float band = 1.0 - smoothstep(0.5, 0.9, M); - float lit = dot(nrm, normalize(vec2(-0.5, -1.0))); - tc *= 1.0 + 0.42 * lit * band; - // Keep the air outline; a softer, screen-width seam also separates rock from loose grains. - float dm = (M - 0.5) / fwM; - tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm)); - float dt = (F.r - 0.5) / fwT; - if (nc == 1 && pw > 0.0) tc *= mix(0.65, 1.0, smoothstep(0.0, 1.5, dt)); - C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT)); - } - -${SM_SLOPE_DRAW} - - // ---- loose grains: little round pebbles; a pocket closed in by four grains is filled - int np = int(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2)); - bool closed = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5; - if (np > 0) { - float best = 1e9; vec3 gc = vec3(0.0); vec2 gd = vec2(0.0); float gr = 0.6; - for (int k = 0; k < 4; k++) { - ivec2 o4 = ivec2(k & 1, k >> 1); - int nk = k == 0 ? n0 : k == 1 ? n1 : k == 2 ? n2 : n3; - if (nk != 2) continue; - vec4 ck = k == 0 ? k0 : k == 1 ? k1 : k == 2 ? k2 : k3; - vec2 ce = vec2(c0 + o4) + 0.5; - float r = 0.6 + 0.1 * (h21(ce + uOrigin) - 0.5); - vec2 dv = (q - ce) / r; - float dd = length(dv); - if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; } - } - float cover = np == 4 ? 1.0 : 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best); - if (closed && F.r < 0.5) cover = 1.0; - // Pebbles stop at the rock face so they cannot paint over its thin seam. - cover *= 1.0 - smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT); - if (cover > 0.0) { - float r2 = min(1.0, dot(gd, gd)); - vec3 n3 = normalize(vec3(gd * 0.85, sqrt(1.0 - r2) + 0.25)); - float sh = 0.98 + 0.18 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6)))); - sh *= mix(1.0, 0.94, smoothstep(0.7 - px * 0.2, 1.15 + px * 0.2, best)); - vec3 pc = (np == 4 ? (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) : gc) * sh; - if (!closed) pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best)); - C = mix(C, pc, cover); - } - } - - // ---- built pieces: crisp, and wall blocks drawn as bevelled tiles with mortar lines - if (nc == 4) C = own.rgb; - else if (nc == 7 || nc == 8) { - // belt: a rubber band round two copper rollers per block; the top run moves the load - float dir = nc == 8 ? 1.0 : -1.0; - vec2 lb = fract(w * 0.25) * 4.0; - // outline: square ends with rounded corners where the line of belts (and filters) ends - float sd = beltLineSD(c, lb); - float inside = 1.0 - smoothstep(-px, px, sd); - if (inside > 0.0) { - float edge = -sd; - vec3 bc; - if (edge < 0.8) { - bc = bandColor(lb, bandRidge(w, lb, dir)); - bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge)); - } else { - vec2 rc = vec2(lb.x < 2.0 ? 1.0 : 3.0, 2.0), dv = lb - rc; - float rd = length(dv); - bc = vec3(0.15, 0.16, 0.18); - if (rd < 0.95) { - float ang = atan(dv.y, dv.x) - dir * uTime * 30.0 / 0.95; - float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0)); - bc = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd)); - bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd)); - bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd)); - } - } - C = mix(C, bc, inside); - } - } - else if ((nc >= 9 && nc <= 12) || (nc >= 19 && nc <= 22)) { - // screw conveyor: a copper pipe; what is inside shows through, and the screw's flights turn - bool vert = nc == 9 || nc == 11; - float sp2 = nc == 9 ? -1.0 : nc == 10 ? 1.0 : nc == 11 ? 1.0 : -1.0; - float a = (vert ? fract(w.x * 0.25) : fract(w.y * 0.25)) * 4.0, along = vert ? w.y : w.x; - float screwSD = -1.0, capRing = 0.0; - vec4 screwTag = cellAt(tTree, c); - int screwJoins = int(screwTag.a * 255.0 + 0.5); - // Straight bend tags use six bits; background trees use 128/255. - bool shaped = nc >= 19 || (screwJoins > 0 && screwJoins < 64); - if (shaped) { - vec2 d = vec2(1.0, nc == 19 || nc == 21 ? -1.0 : 1.0); - vec4 tag = screwTag; - vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w) - 2.0; - int joins = screwJoins; - screwSD = 100.0; - for (int end = 0; end < 2; end++) { - int join = (joins >> (end * 3)) & 7; - float side = end == 0 ? -1.0 : 1.0; - bool diagonal = nc >= 19 ? join == 0 || join >= 5 : join != 0; - float grade = nc >= 19 ? d.y : join == 1 ? -1.0 : 1.0; - vec2 ray = diagonal ? (vert ? vec2(side * grade, side) : vec2(side, side * grade)) * 0.70710678 : - nc < 19 ? (vert ? vec2(0, side) : vec2(side, 0)) : - join == 1 ? vec2(0, -1) : join == 2 ? vec2(1, 0) : join == 3 ? vec2(0, 1) : vec2(-1, 0); - float t = dot(local, ray), cross2 = dot(local, vec2(-ray.y, ray.x)); - float radius = length(vec2(cross2, min(0.0, t))), cap = t - (diagonal ? 2.82842712 : 2.0); - // A connected mouth meets another opaque pipe; fading its edge leaves a seam. - float sd = max(radius - 2.0, (joins & (64 << end)) != 0 ? cap : -10.0); - if (sd < screwSD) { - screwSD = sd; - a = 2.0 + (side * cross2 < 0.0 ? -radius : radius); - along = (nc >= 19 ? dot(w - local, d) * 0.70710678 : vert ? w.y - local.y : w.x - local.x) + side * t; - capRing = (joins & (64 << end)) != 0 ? smoothstep(-0.55, -0.15, cap) : 0.0; - } - } - if (nc >= 19) sp2 = nc == 19 || nc == 20 ? 1.0 : -1.0; - } - float wall = 0.62; - vec3 tc; - if (a < wall || a > 4.0 - wall || capRing > 0.5) { - float u2 = a < wall ? a / wall : (4.0 - a) / wall; // 0 at the outside, 1 at the bore - float cyl = sin(clamp(u2, 0.0, 1.0) * 1.5708); - tc = vec3(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl); - if (a < wall) tc *= 1.08; - float ring = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0); - if (nc < 19) tc *= 1.0 - 0.3 * ring; - tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a))); - } else { - bool full = own.r + own.g + own.b > 0.0; - float fl = fract((along + a * 0.9 - sp2 * uTime * 14.0) * 0.5); - float flight = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl)); - vec3 bore = vec3(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4)); - tc = full ? own.rgb * (0.9 + 0.1 * vn(w * 1.3)) : bore; - tc = mix(tc, vec3(0.6, 0.48, 0.38), flight * (full ? 0.35 : 0.8)); - tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0)); - } - if (shaped) { - bool full = own.r + own.g + own.b > 0.0; - if (full) C = mix(C, own.rgb, 1.0 - smoothstep(0.36, 0.5, length(fract(w) - 0.5))); - C = mix(C, tc, 1.0 - smoothstep(2.0 - px, 2.0 + px, screwSD + 2.0)); - } else C = tc; - } - else if (nc == 6 || (nc >= 15 && nc <= 18)) { - vec2 lb = fract(w * 0.25) * 4.0, rb = 4.0 - lb; - float e = min(min(lb.x, lb.y), min(rb.x, rb.y)); - vec3 tc = own.rgb * (0.92 + 0.14 * fbm(w * 0.55)); - float hl = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y)); - float shd = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y)); - tc *= 1.0 + 0.2 * hl - 0.26 * shd; - tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e)); - if (nc >= 15) { - vec2 sl = lb; - if (nc == 16 || nc == 18) sl.x = 4.0 - sl.x; - if (nc >= 17) sl.y = 4.0 - sl.y; - float d = (sl.y - sl.x) * 0.70710678; - tc *= 1.0 + 0.2 * (1.0 - smoothstep(0.0, 0.6, d)); - C = mix(C, tc, smoothstep(-px * 0.5, px * 0.5, d)); - } else C = tc; - } - - else if (nc == 13 || nc == 14) { - // filter: a belt whose middle is a see-through steel mesh. It has the same band as a belt, so a - // row of belts and filters reads as one belt, rounded only where the whole line ends. The - // band is perforated; its colour tag sits on the top run for what rides along, on the - // bottom run for what drops through. - float dir = nc == 14 ? 1.0 : -1.0; - vec2 lb = fract(w * 0.25) * 4.0; - vec4 tag = cellAt(tTree, c); - bool carry = tag.a > 0.16, tinted = tag.r + tag.g + tag.b > 0.0; - float sd = beltLineSD(c, lb); - float inside = 1.0 - smoothstep(-px, px, sd); - if (inside > 0.0) { - float edge = -sd; - vec3 steel = vec3(0.42, 0.44, 0.5); - // through the holes: whatever is behind (water, sky, rock), or what is passing through - vec3 behind = own.r + own.g + own.b > 0.0 ? own.rgb : C * 0.8; - vec2 cg = abs(fract(w) - 0.5); - float bw2 = max(px * 0.9, 0.09); - float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y)); - vec3 fc; - if (edge < 0.8) { - float rg = bandRidge(w, lb, dir); - vec3 band = bandColor(lb, rg); - if (tinted && (carry == (lb.y < 2.0))) band = mix(band, tag.rgb * (lb.y < 2.0 ? 1.0 : 0.8), 0.75 * rg); - fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75); - fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge)); - } else { - fc = mix(steel * 0.78, behind, hole); - fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8)); - } - C = mix(C, fc, inside); - } - } - - // ---- light, then the glow of hot things on top - if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb; - if (uGlowK > 0.0) { - vec2 gu = q / (2.0 * uGlowSize), d = 1.0 / uGlowSize; - vec3 g = textureLod(tGlow, gu, 0.0).rgb * 0.25; - g += (textureLod(tGlow, gu + vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu - vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu + vec2(0, d.y), 0.0).rgb + textureLod(tGlow, gu - vec2(0, d.y), 0.0).rgb) * 0.125; - g += (textureLod(tGlow, gu + d, 0.0).rgb + textureLod(tGlow, gu - d, 0.0).rgb + textureLod(tGlow, gu + vec2(d.x, -d.y), 0.0).rgb + textureLod(tGlow, gu + vec2(-d.x, d.y), 0.0).rgb) * 0.0625; - C += g * uGlowK; - } - o = vec4(C, 1.0); -}`; - diff --git a/src/render/smooth.js b/src/render/smooth.js deleted file mode 100644 index e0fa7eb..0000000 --- a/src/render/smooth.js +++ /dev/null @@ -1,108 +0,0 @@ -// Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution. -const SM = { tried: false, ok: false, why: '', gl: null, canvas: null, prog: null, tex: {}, u: {}, t0: performance.now(), white: null, surf: -1, scale: 1, ms: 0, n: 0 }; - -function smCompile(gl, type, src) { - const sh = gl.createShader(type); - gl.shaderSource(sh, src); gl.compileShader(sh); - if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh)); - return sh; -} -// Is the smooth look possible here (WebGL2)? Set up once, on first use. -function smoothAvailable() { - if (SM.tried) return SM.ok; - SM.tried = true; - try { - const cv = document.createElement('canvas'); - const gl = cv.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: true, premultipliedAlpha: false }); - if (!gl) { SM.why = 'nogl'; throw new Error('WebGL2 is not available'); } - // a browser drawing without the graphics card would crawl: keep the pixel look there - const dbg = gl.getExtension('WEBGL_debug_renderer_info'); - const rend = dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : ''; - if (/swiftshader|llvmpipe|softpipe|software/i.test(rend) && !window.__allowSoftwareGL) { SM.why = 'software'; throw new Error('software rendering: ' + rend); } - const prog = gl.createProgram(); - gl.attachShader(prog, smCompile(gl, gl.VERTEX_SHADER, SM_VS)); - gl.attachShader(prog, smCompile(gl, gl.FRAGMENT_SHADER, SM_FS)); - gl.linkProgram(prog); - if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog)); - gl.useProgram(prog); - for (const n of ['uSize', 'uOrigin', 'uLightSize', 'uGlowSize', 'uView', 'uLightMap', 'uGlowK', 'uTime', 'uSurf', 'uRain']) SM.u[n] = gl.getUniformLocation(prog, n); - // texture unit for each sampler; linear ones are sampled between grains - const texs = [['tCol', false, false], ['tColL', true, false], ['tBack', true, false], ['tTree', false, false], ['tField', true, true], ['tLight', true, false], ['tGlow', true, false]]; - texs.forEach(([name, linear, mip], unit) => { - const t = gl.createTexture(); - gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, t); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mip ? gl.LINEAR_MIPMAP_LINEAR : linear ? gl.LINEAR : gl.NEAREST); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - gl.uniform1i(gl.getUniformLocation(prog, name), unit); - SM.tex[name] = { t, unit, mip, w: 0, h: 0 }; - }); - gl.bindVertexArray(gl.createVertexArray()); - SM.canvas = cv; SM.gl = gl; SM.prog = prog; SM.ok = true; - SM.white = new Uint8Array([255, 255, 255, 255]); - cv.addEventListener('webglcontextlost', e => { e.preventDefault(); SM.ok = false; }); - } catch (e) { - console.warn('Smooth look unavailable, using pixels:', e && e.message); - SM.ok = false; - } - return SM.ok; -} -function smUpload(name, w, h, data) { - const gl = SM.gl, T = SM.tex[name]; - gl.activeTexture(gl.TEXTURE0 + T.unit); gl.bindTexture(gl.TEXTURE_2D, T.t); - if (T.w !== w || T.h !== h) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); T.w = w; T.h = h; } - else gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, data); - if (T.mip) gl.generateMipmap(gl.TEXTURE_2D); -} -const smBytes = a => new Uint8Array(a.buffer, a.byteOffset, a.byteLength); -// Both renderers share the same cached parallax horizon. -function smoothSurface() { - if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { SM.surf = meanSurface(); SM.surfW = W; SM.surfSeed = worldSeed; } - return SM.surf; -} -function smoothDraw(lighting, glowOn) { - const t0 = performance.now(); - // the GPU pass can run below full resolution on a slow graphics card; it is scaled up smoothly - const gl = SM.gl, cv = SM.canvas, sc = SM.scale, cw = Math.max(1, Math.round(R.canvas.width * sc)), ch = Math.max(1, Math.round(R.canvas.height * sc)), u = SM.u; - if (cv.width !== cw || cv.height !== ch) { cv.width = cw; cv.height = ch; } - const surf = smoothSurface(); - gl.viewport(0, 0, cw, ch); - gl.useProgram(SM.prog); - const bw = R.bw, bh = R.bh; - const uploadStart = PERF_MARKS ? performance.now() : 0; - smUpload('tCol', bw, bh, smBytes(R.px)); - smUpload('tColL', bw, bh, smBytes(R.px)); - smUpload('tBack', bw, bh, smBytes(R.backPx)); - smUpload('tTree', bw, bh, smBytes(R.treePx)); - smUpload('tField', bw, bh, smBytes(R.fieldPx)); - if (lighting && R.lightImg) smUpload('tLight', R.lw, R.lh, smBytes(R.lightImg.data)); else smUpload('tLight', 1, 1, SM.white); - if (glowOn) smUpload('tGlow', R.glowC.width, R.glowC.height, smBytes(R.glowImg.data)); else smUpload('tGlow', 1, 1, SM.white); - const blitStart = PERF_MARKS ? performance.now() : 0; - if (PERF_MARKS) { perfMarks.upload = blitStart - uploadStart; perfMarks.prepare += uploadStart - t0; } - gl.uniform2f(u.uSize, bw, bh); - gl.uniform2f(u.uOrigin, R.vx0, R.vy0); - gl.uniform4f(u.uView, R.camX - R.vx0, R.camY - R.vy0, R.zp * (ch / R.canvas.height), ch); - gl.uniform4f(u.uLightMap, lighting ? R.lx0 * R.lres - R.vx0 : 0, lighting ? R.ly0 * R.lres - R.vy0 : 0, R.lres || 1, lighting ? 1 : 0); - gl.uniform2f(u.uLightSize, R.lw || 1, R.lh || 1); - gl.uniform2f(u.uGlowSize, R.glowC.width || 1, R.glowC.height || 1); - gl.uniform1f(u.uGlowK, glowOn ? (SET.glow === 'high' ? 0.9 : 0.55) : 0); - gl.uniform1f(u.uTime, (performance.now() - SM.t0) / 1000); - gl.uniform1f(u.uSurf, surf); - gl.uniform1f(u.uRain, R.rain || 0); - gl.drawArrays(gl.TRIANGLES, 0, 3); - const ctx = R.ctx; - ctx.imageSmoothingEnabled = true; - ctx.drawImage(cv, 0, 0, cw, ch, 0, 0, R.canvas.width, R.canvas.height); - if (PERF_MARKS) perfMarks.blit = performance.now() - blitStart; - smoothPace(performance.now() - t0); -} -// Keep the frame rate up: drop the GPU pass's resolution when it is slow, raise it again when there -// is room, and if even half resolution is too slow, give up on the smooth look for this session. -function smoothPace(ms) { - SM.ms = SM.n ? SM.ms * 0.8 + ms * 0.2 : ms; - if (++SM.n < 15) return; - SM.n = 1; - if (SM.ms > 40 && SM.scale <= 0.5) { SM.ok = false; SM.why = 'slow'; return; } - if (SM.ms > 18 && SM.scale > 0.5) SM.scale = Math.max(0.5, SM.scale - (SM.ms > 60 ? 0.5 : 0.25)); - else if (SM.ms < 6 && SM.scale < 1) SM.scale = Math.min(1, SM.scale + 0.25); -} diff --git a/src/render/world.js b/src/render/world.js index 723d168..7275e43 100644 --- a/src/render/world.js +++ b/src/render/world.js @@ -5,18 +5,16 @@ function fogColor(c, k) { return (c & 0xff000000) | (((c >>> 16 & 255) * k) << 1 function drawWorld() { const perfStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0; if (PERF_MARKS || SET.showPerf) { perfMarks.light = 0; perfMarks.override = 0; delete perfMarks.gpu; } - const wasGpu = R.gpuOn; - R.gpuOn = SET.renderer === 'webgpu' && GPU.ok; - if (R.gpuOn && !wasGpu) GPU.lightFrame = undefined; - GPU.canvas.hidden = !R.gpuOn; - if (R.gpuOn) R.ctx.clearRect(0, 0, R.canvas.width, R.canvas.height); + if (!GPU.ok) return; + R.gpuOn = true; GPU.canvas.hidden = false; + 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; // Smooth inputs carry a class in alpha; only exceptional cells use the CPU in WebGPU mode. - const sm = R.smoothOn = SET.look === 'smooth' && !R.lens && (R.gpuOn || smoothAvailable()); + const sm = R.smoothOn = SET.look === 'smooth' && !R.lens; const mmask = R.moldMask; if (sm) { mmask.fill(0); @@ -34,52 +32,15 @@ function drawWorld() { } prepareSlopeBelts(vx0, vy0, vx0 + bw, vy0 + bh); const lighting = SET.lighting !== 'off' && !R.lens; - const lightUpdate = R.gpuOn && lighting ? gpuLightGrid() : false; - if (!R.gpuOn && lighting && (R.frame % SET.lightEvery === 0 || !R.lw || wasGpu)) { - const lightStart = PERF_MARKS ? performance.now() : 0; - computeLight(); - if (PERF_MARKS) perfMarks.light = performance.now() - lightStart; - } + const lightUpdate = lighting ? gpuLightGrid() : false; 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); + gpuOverrides(); worldObjects(); if (PERF_MARKS || SET.showPerf) { perfMarks.color = performance.now() - colorStart; perfMarks.prepare = colorStart - perfStart - perfMarks.light; } - if (R.gpuOn) { gpuDraw(lighting, glowOn, lightUpdate); return; } - if (sm) { smoothDraw(lighting, glowOn); return; } - const uploadStart = PERF_MARKS ? performance.now() : 0; - R.bctx.putImageData(R.img, 0, 0); - const blitStart = PERF_MARKS ? performance.now() : 0; - if (PERF_MARKS) perfMarks.upload = blitStart - uploadStart; - const ctx = R.ctx, zp = R.zp; - ctx.imageSmoothingEnabled = false; - const ox = -(R.camX - vx0) * zp, oy = -(R.camY - vy0) * zp; - ctx.drawImage(R.buf, 0, 0, bw, bh, Math.round(ox), Math.round(oy), bw * zp, bh * zp); - if (lighting) { - const res = R.lres; - ctx.save(); - ctx.globalCompositeOperation = 'multiply'; - ctx.imageSmoothingEnabled = true; - const lx = (R.lx0 * res - R.camX) * zp, ly = (R.ly0 * res - R.camY) * zp; - ctx.drawImage(R.lightC, 0, 0, R.lw, R.lh, lx, ly, R.lw * res * zp, R.lh * res * zp); - ctx.restore(); - } - if (glowOn) { - R.glowX.putImageData(R.glowImg, 0, 0); - ctx.save(); - ctx.globalCompositeOperation = 'lighter'; - ctx.globalAlpha = SET.glow === 'high' ? 0.9 : 0.5; - ctx.imageSmoothingEnabled = true; - const blur = Math.max(2, zp * (SET.glow === 'high' ? 3.2 : 2.2)); - try { ctx.filter = `blur(${blur.toFixed(1)}px)`; } catch (e) { /* no filter support */ } - ctx.drawImage(R.glowC, 0, 0, R.glowC.width, R.glowC.height, Math.round(ox), Math.round(oy), R.glowC.width * 2 * zp, R.glowC.height * 2 * zp); - ctx.filter = 'none'; - ctx.restore(); - } - if (PERF_MARKS) perfMarks.blit = performance.now() - blitStart; + gpuDraw(lighting, glowOn, lightUpdate); } diff --git a/src/sim/actions.js b/src/sim/actions.js index 9d632a0..fad078a 100644 --- a/src/sim/actions.js +++ b/src/sim/actions.js @@ -60,7 +60,7 @@ function toggleGateAt(x, y) { const m = mat[i]; if (m !== EMPTY && PH[m] !== PH_GAS) { const u = i - W; - if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; } + if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; renderDirtyIdx(u); } } part[i] = 0; mat[i] = GATE; flags[i] = 0; life[i] = 0; } @@ -82,7 +82,7 @@ function interactAt(x, y) { return open ? 'Rack open.' : 'Rack closed.'; } if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) { - blockV[pb] ^= 4; emit({ t: 'sfx', s: 'ui' }); + blockV[pb] ^= 4; renderDirtyXY((pb % BW) * BS, ((pb / BW) | 0) * BS); emit({ t: 'sfx', s: 'ui' }); return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.'); } const id = mach[y * W + x]; diff --git a/src/sim/blocks.js b/src/sim/blocks.js index 380d497..e877f2b 100644 --- a/src/sim/blocks.js +++ b/src/sim/blocks.js @@ -83,6 +83,7 @@ function placeBlock(code, v, bx, by, free, extra) { else if (isPipeCode(code)) setCell(i, PIPE, AMB[y], 0); } if (isPipeCode(code)) pipesDirty = true; + renderDirtyXY(bx * BS, by * BS); blockP[by * BW + bx] = code; blockV[by * BW + bx] = v; if (isFilter(code)) { blockV[by * BW + bx] = filterVariant(code, v); filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; } if (isProcCode(code)) procDirty = true; @@ -169,6 +170,7 @@ function removeBlock(bx, by) { rackState.delete(b); if (s) for (const [m, count] of s.q) giveOrDrop(m, count, bot.x, bot.y); } + renderDirtyXY(bx * BS, by * BS); blockP[b] = 0; blockV[b] = 0; const cleared = blockCellList(bx, by); if (isSlope(code) || isSlopeBelt(code)) { diff --git a/src/sim/cells.js b/src/sim/cells.js index 0a5a8e4..d6f6b23 100644 --- a/src/sim/cells.js +++ b/src/sim/cells.js @@ -12,6 +12,7 @@ function setCell(i, m, t, fl) { if (t - AMB[y] > 20 || AMB[y] - t > 20 || (MELT[m] !== 0 && AMB[y] >= MELT[m]) || (m === WATER && AMB[y] < -0.5)) wakeHeatXY(x, y); } function swapCells(i, j) { + renderDirtyIdx(i); renderDirtyIdx(j); let a = mat[i]; mat[i] = mat[j]; mat[j] = a; const f = temp[i]; temp[i] = temp[j]; temp[j] = f; a = life[i]; life[i] = life[j]; life[j] = a; @@ -149,6 +150,7 @@ function liquid(i, x, y, m) { function gas(i, x, y, m) { // Even blocked gas must keep aging or condensing until it is gone. awake[(y >> 5) * CW + (x >> 5)] = 3; + renderDirtyXY(x, y); // Gas can age without moving. if (m === FIRE) { const l = life[i]; if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; } diff --git a/src/sim/conveyors.js b/src/sim/conveyors.js index d8bfd16..dc146d4 100644 --- a/src/sim/conveyors.js +++ b/src/sim/conveyors.js @@ -114,6 +114,7 @@ function carryBeltsAndFilters(belts, filters, d, bm, carrying) { } // Change what a filter does; grains inside it that no longer pass are pushed out on top. function setFilter(b, cfg) { + renderDirtyXY((b % BW) * BS, ((b / BW) | 0) * BS); if (!isFilter(blockP[b])) return false; const rec = filterRec(cfg); filterCfg.set(b, rec); diff --git a/src/sim/drill.js b/src/sim/drill.js index 3b421b1..d4329a3 100644 --- a/src/sim/drill.js +++ b/src/sim/drill.js @@ -151,7 +151,7 @@ function pulseTick() { const thin = open > 1 && !THIN_BONUS_OFF ? 1 + (open - 1) * THIN_BONUS : 1; if (h > tier.hard) { e *= 0.35; continue; } const q = digp[i] + e * thin * tier.speed * PULSE_K / (1 + h * h * 0.6); - if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else digp[i] = q; + if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else { digp[i] = q; renderDirtyIdx(i); } e *= p.tune ? 0.96 : p.damp; } p.en[k] = e; p.s[k] = sd; diff --git a/src/sim/fog.js b/src/sim/fog.js index 8d5fe86..dd3c093 100644 --- a/src/sim/fog.js +++ b/src/sim/fog.js @@ -7,7 +7,7 @@ function fogAlloc() { FW = Math.ceil(W / FOG); FH = Math.ceil(H / FOG); fog = ne function fogMark(x, y, v) { if (x < 0 || y < 0 || x >= W || y >= H) return; const i = Math.floor(y / FOG) * FW + Math.floor(x / FOG); - if (v > fog[i]) fog[i] = v; + if (v > fog[i]) { fog[i] = v; renderDirtyXY(x, y); } } function fogSeen(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return 255; @@ -39,7 +39,7 @@ function fogLook(cx, cy) { const dx = (fx + 0.5) * FOG - cx, dy = (fy + 0.5) * FOG - cy, d2 = dx * dx + dy * dy, i = fy * FW + fx; if (d2 >= R2) continue; const v = d2 <= in2 ? 255 : (FOG_SENSE - Math.sqrt(d2)) * 255 / FOG_SENSE_FADE; - if (v > fog[i]) fog[i] = v; + if (v > fog[i]) { fog[i] = v; renderDirtyXY(fx * FOG, fy * FOG); } } } function fogRevealAround(x, y, r) { @@ -49,4 +49,4 @@ function fogRevealAround(x, y, r) { fogMark(xx, yy, r >= 6 && d2 <= (r - 6) ** 2 ? 255 : Math.min(255, (r - Math.sqrt(d2)) * 255 / 6)); } } -function fogRevealAll() { fog.fill(255); } +function fogRevealAll() { fog.fill(255); renderDirty.fill(1); } diff --git a/src/sim/heat.js b/src/sim/heat.js index 491d6cd..e3d4402 100644 --- a/src/sim/heat.js +++ b/src/sim/heat.js @@ -65,6 +65,11 @@ function heatPass() { function heatChunk(c, sc) { const cx = c % CW, cy = (c / CW) | 0; const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32); + renderDirty[c] = 1; + if (cx > 0) renderDirty[c - 1] = 1; + if (cx + 1 < CW) renderDirty[c + 1] = 1; + if (cy > 0) renderDirty[c - CW] = 1; + if (cy + 1 < CH) renderDirty[c + CW] = 1; roofDirty[cx] = 1; const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW]; let maxDev = 0, burning = false; diff --git a/src/sim/machines.js b/src/sim/machines.js index 6ed66d9..1070442 100644 --- a/src/sim/machines.js +++ b/src/sim/machines.js @@ -57,7 +57,7 @@ function netSat(i) { if (POWER_FREE) return 1; const n = nets[netId[i]]; return // Move loose grains out of the given cells, to the nearest open cell above // the footprint in the same column (they then fall back on top). Gas just vanishes. function evictCells(cells, topY) { - for (const i of cells) bgm[i] = 0; + for (const i of cells) { bgm[i] = 0; renderDirtyIdx(i); } for (const i of cells) { const m = mat[i], ph = PH[m]; if (m === EMPTY || ph === PH_SOLID) continue; diff --git a/src/sim/nature.js b/src/sim/nature.js index 57bd32a..c28e886 100644 --- a/src/sim/nature.js +++ b/src/sim/nature.js @@ -4,6 +4,7 @@ // no longer reaches the ground comes down: every trunk cell drops as wood chunks. function cutTree(i, x, y, tier, amt = 1) { const b = bgm[i]; + renderDirtyIdx(i); const p = digp[i] + amt * tier.speed * 70 / (1 + HARD[b]); if (p < 255) { digp[i] = p; return false; } digp[i] = 0; bgm[i] = 0; @@ -104,6 +105,7 @@ function plantTick() { if (y <= 6 || y >= H - 1) continue; const i = y * W + x; if (mat[i] !== ACORN || (flags[i] & F_BURN)) continue; + renderDirtyIdx(i); if (!SOIL[mat[i + W]] || temp[i] < 2 || temp[i] > 45) { life[i] = 0; continue; } let room = true; for (let k = 1; k <= 6 && room; k++) { const j = i - k * W; if (PH[mat[j]] !== PH_EMPTY && PH[mat[j]] !== PH_GAS || bgm[j] === WOOD) room = false; } @@ -120,7 +122,7 @@ function setTreeCell(x, y, m) { const i = y * W + x; if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return; if (m === LEAVES && bgm[i]) return; - bgm[i] = m; + bgm[i] = m; renderDirtyIdx(i); if (treeDraw) treeDraw.push(i); } function saplingTick() { @@ -134,7 +136,7 @@ function saplingTick() { sp.age++; const s = Math.min(1, sp.age / 180); // wipe the previous stage, then draw this one - if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) bgm[i] = 0; + if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) { bgm[i] = 0; renderDirtyIdx(i); } treeDraw = sp.cells = []; const th = Math.max(1, Math.round(sp.th * s)), wide = s > 0.5 ? 2 : 1; for (let j = 1; j <= th; j++) for (let dx = 0; dx < wide; dx++) setTreeCell(sp.x + dx, sp.y - j, WOOD); diff --git a/src/sim/tools.js b/src/sim/tools.js index b86bed1..c5e9ae1 100644 --- a/src/sim/tools.js +++ b/src/sim/tools.js @@ -50,7 +50,7 @@ function toolDig(cx, cy, r) { if (isWall(b) && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) removeBlock((x / BS) | 0, (y / BS) | 0); dug++; if ((dug & 3) === 0) emit({ t: 'debris', x, y, m }); - } else digp[i] = p; + } else { digp[i] = p; renderDirtyIdx(i); } }); stats.dug += dug; let status = ''; diff --git a/src/sim/world.js b/src/sim/world.js index 68c1ec4..a43e042 100644 --- a/src/sim/world.js +++ b/src/sim/world.js @@ -13,6 +13,7 @@ let ents = []; // loose objects (bars) let bgm; // background layer: trees. Liquids, powders and gases pass in front of it. let flyers = []; // grains in flight, blown out of the bot let ghostBlocks = new Map(), ghostMolds = []; +let renderDirty; // GPU world textures, one invalidation bit per 32x32 chunk. let awake, hot, hotLvl, powderArea, roofDirty; let loadWakeOrder = null, loadWakePending = null, loadWakeIndex = 0; let tick = 0, tickStamp = 1; @@ -53,6 +54,7 @@ function alloc(w, h) { surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w); AMB = new Float32Array(h); loadWakeOrder = loadWakePending = null; loadWakeIndex = 0; + renderDirty = new Uint8Array(NCH).fill(1); cleatChunks = new Uint8Array(NCH); awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH); roofDirty = new Uint8Array(CW).fill(1); @@ -90,7 +92,10 @@ function fillNoise() { } // ---------- Waking ---------- +function renderDirtyXY(x, y) { renderDirty[(y >> 5) * CW + (x >> 5)] = 1; } +function renderDirtyIdx(i) { renderDirtyXY(i % W, (i / W) | 0); } function wakeXY(x, y) { + renderDirtyXY(x, y); const cx = x >> 5, cy = y >> 5, c = cy * CW + cx; roofDirty[cx] = 1; awake[c] = 3; @@ -99,6 +104,7 @@ function wakeXY(x, y) { if (ly === 0) { if (cy > 0) awake[c - CW] = 3; } else if (ly === 31 && cy < CH - 1) awake[c + CW] = 3; } function wakeHeatXY(x, y) { + renderDirtyXY(x, y); const c = (y >> 5) * CW + (x >> 5); roofDirty[x >> 5] = 1; hot[c] = 6; hotLvl[c] = 2; diff --git a/src/ui/claims.js b/src/ui/claims.js index 60ce110..91adbe1 100644 --- a/src/ui/claims.js +++ b/src/ui/claims.js @@ -89,7 +89,8 @@ async function openNewClaim() { showLoading(true, 0.9, 'Saving'); if (!await saveWorld('auto', true)) throw new Error('Save failed'); showLoading(true, 1, 'Saving'); - showLoading(false); closeAllModals(); enterGame(); + await gpuWarmWorld(); + showLoading(false); closeAllModals(); enterGame(true); showCard({ kind: 'New claim', title: name, body: [`Claim ${n}. You owe ${fmtMoney(debt)} on this claim. Your money, bag, upgrades and what you’ve learned came along. Buildings stayed on the old claim.`], hold: 1.8 }); return true; } catch (e) { diff --git a/src/ui/loop.js b/src/ui/loop.js index bf5020f..9c0455d 100644 --- a/src/ui/loop.js +++ b/src/ui/loop.js @@ -12,7 +12,9 @@ function scanVisible() { } let ambientT = 0; function frame(now) { + if (GPU.stopped) return; requestAnimationFrame(frame); + if (GPU.recovering) { lastT = now; return; } const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now; if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return; const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now; @@ -58,19 +60,12 @@ function frame(now) { if (!GAME.drag || Math.hypot(bot.vx, bot.vy) > 0.08) updateCamera(dt, lookX, lookY); { const v = viewCells(); setBuildRange(R.camX + v.w / 2, R.camY + v.h / 2, v.w, v.h); } R.frame++; - if (SET.showPerf) perfLast.computeLight = 0; drawWorld(); - if (SET.showPerf) perfTimes.computeLight = (perfTimes.computeLight || 0) * 0.9 + perfLast.computeLight * 0.1; const overlayStart = SET.showPerf ? performance.now() : 0; - drawCracks(); if (!weather.seen && (R.rain || 0) > 0.12) { weather.seen = true; showCard({ kind: 'Weather', title: 'Rain', swatch: WATER, body: ['Steam that rises into the open sky gathers into clouds and comes back down as rain nearby. On freezing heights it falls as snow.', 'Water is never used up, only moved around. If some leaves the world (dropped into the dock, say), a shower from beyond the claim makes it up.'] }); } - if (SET.look === 'smooth' && !R.gpuOn && SM.tried && !SM.ok) { - SET.look = 'pixel'; saveSettings(); - toast(SM.why === 'slow' ? 'The smooth look was too slow on this computer, so the pixel look is on. You can try it again in Settings.' : SM.why === 'software' ? 'This browser is drawing without the graphics card, so the smooth look would crawl. The pixel look is on.' : 'This browser cannot draw the smooth look, so the pixel look is on.'); - } drawMachines(); drawMolds(moldAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y))); drawParticles(); @@ -101,7 +96,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 ${R.gpuOn ? "" : `· 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)} ms (light ${(GPU.lightMs || 0).toFixed(1)})` : ""} · 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 · overrides ${(perfMarks.override || 0).toFixed(1)} · upload ${(perfMarks.upload || 0).toFixed(1)} · GPU ${GPU.gpuMs === null ? "—" : GPU.gpuMs.toFixed(1)} ms (light ${(GPU.lightMs || 0).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/perf.js b/src/ui/perf.js index 00a7cb8..86b5cce 100644 --- a/src/ui/perf.js +++ b/src/ui/perf.js @@ -1,7 +1,7 @@ // Browser-only clocks; simulation code stays usable without a browser or a clock. let PERF_MARKS = false, PERF_FRAME = null; const perfMarks = {}, perfTimes = {}, perfLast = {}; -for (const name of ['drawWorld', 'computeLight', 'simMove', 'heatPass']) { +for (const name of ['drawWorld', 'simMove', 'heatPass']) { const original = window[name]; window[name] = function (...args) { if (!SET.showPerf && !PERF_MARKS) return original.apply(this, args); @@ -9,7 +9,7 @@ for (const name of ['drawWorld', 'computeLight', 'simMove', 'heatPass']) { const result = original.apply(this, args); const ms = performance.now() - start; perfLast[name] = ms; - if (name !== 'computeLight') perfTimes[name] = (perfTimes[name] || 0) * 0.9 + ms * 0.1; + perfTimes[name] = (perfTimes[name] || 0) * 0.9 + ms * 0.1; return result; }; } diff --git a/src/ui/preview-drill.js b/src/ui/preview-drill.js index b4a7656..e82f8bd 100644 --- a/src/ui/preview-drill.js +++ b/src/ui/preview-drill.js @@ -1,44 +1,4 @@ -// Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. -// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. -// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines -// run on unbroken from one cell to the next and look like real fractures, not a pattern. -const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))]; -const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }]; -function crackSeg(ctx, x, y, n, sc, zp, oy) { - const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61); - const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1); - const pts = []; - if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y); - if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c)); - if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1); - if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d)); - for (let k = 0; k + 3 < pts.length; k += 4) { - ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy); - ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy); - } -} -function drawCracks() { - const ctx = R.ctx, zp = R.zp; - if (zp < 2.5) return; - const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY)); - const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp)); - const cells = []; - for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } } - if (!cells.length) return; - ctx.save(); ctx.lineCap = 'round'; - for (let l = 0; l < CRACK_LV.length; l++) { - const L = CRACK_LV[l]; - // a soft lit lip just below each crack, then the dark crack itself - for (const pass of [0, 1]) { - ctx.beginPath(); - for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12)); - ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)'; - ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3)); - ctx.stroke(); - } - } - ctx.restore(); -} +// Drill pulse wavefronts and aiming fan. // The pulses in flight: a wavefront running out from the face of the bot, fading as it goes function pulseRGB(tune) { if (!tune) return '150,215,255'; diff --git a/src/ui/session.js b/src/ui/session.js index ec4fcbd..3580940 100644 --- a/src/ui/session.js +++ b/src/ui/session.js @@ -10,14 +10,15 @@ function showLoading(on, p, label, title) { $('loading').hidden = !on; if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; } } -function enterGame() { +function enterGame(renderReady = false) { closeDevConsole(); $('dev-output').textContent = ''; $('dev-input').value = ''; $('cards').innerHTML = ''; if (R.floaters) R.floaters.length = 0; R.particles.length = 0; GAME.status = ''; GAME.statusT = 0; $('title').hidden = true; $('hud').hidden = false; GAME.running = true; GAME.paused = false; - buildSky(); resizeRender(); snapCamera(); resetMap(); + if (!renderReady) { buildSky(); resizeRender(); snapCamera(); } + resetMap(); GAME.lastAutosave = performance.now(); GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = ''; hudDirty = true; renderHotbar(); @@ -41,7 +42,8 @@ async function startNewWorld(seed, w, h, name = '') { showLoading(true, 0.9, 'Saving'); await saveWorld('auto'); showLoading(true, 1, 'Saving'); - showLoading(false); closeAllModals(); enterGame(); + await gpuWarmWorld(); + showLoading(false); closeAllModals(); enterGame(true); showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 }); } catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); } } @@ -67,8 +69,8 @@ async function startFromSave(sv, onProgress) { GAME.drag = null; GAME.blow.clear(); onProgress(0.95, 'Your gear and notebook'); await loadCareer(); - showLoading(false); - enterGame(); + await gpuWarmWorld(); + showLoading(false); enterGame(true); return true; } async function loadSavedWorld(id) { diff --git a/src/ui/settings.js b/src/ui/settings.js index 1072835..e04f68c 100644 --- a/src/ui/settings.js +++ b/src/ui/settings.js @@ -16,7 +16,6 @@ const SET_TABS = { { k: 'shake', label: 'Screen shake', type: 'toggle' }, ], Graphics: [ - { k: 'renderer', label: 'Renderer', d: 'WebGPU draws both looks on the graphics card. Legacy 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']] }, @@ -98,8 +97,6 @@ function renderSettings() { } } function applySettings() { - // choosing Smooth again after it was too slow: try once more, at half resolution - if (SET.look === 'smooth' && SM.tried && !SM.ok && SM.why === 'slow' && SM.gl) { SM.ok = true; SM.why = ''; SM.scale = 0.5; SM.n = 0; } saveSettings(); document.documentElement.style.setProperty('--ui', SET.uiScale); if (R.canvas) { resizeRender(); R.snapNext = true; } diff --git a/src/ui/state.js b/src/ui/state.js index 78c07dc..d12a0f2 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, - renderer: 'webgpu', look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true, + 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 003e0ea..d2a06e0 100644 --- a/src/ui/title.js +++ b/src/ui/title.js @@ -1,15 +1,15 @@ // Startup capabilities and title art. -const REQUIRE_GPU_AND_THREADS = false; +const REQUIRE_WEBGPU = true; +const REQUIRE_THREADS = false; const CAPS = { isolated: self.crossOriginIsolated === true, webgpu: false, adapterInfo: null, ready: false }; async function checkCapabilities() { try { const adapter = navigator.gpu && await navigator.gpu.requestAdapter(); - CAPS.webgpu = !!adapter; - if (adapter) await gpuInit(adapter); - else SET.renderer = 'legacy'; + CAPS.webgpu = !!adapter && gpuLimitsOK(adapter) && await gpuInit(adapter); + CAPS.requiredLimits = GPU_LIMITS; 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; SET.renderer = 'legacy'; } + } catch (e) { CAPS.webgpu = false; } CAPS.ready = true; renderCapabilityNotice(); } @@ -19,13 +19,14 @@ function renderCapabilityNotice() { if (!CAPS.webgpu) messages.push(/Firefox\//.test(navigator.userAgent) ? 'WebGPU is unavailable. In Firefox, open about:config and set dom.webgpu.enabled to true, then restart. On Linux, use Firefox Nightly. If no graphics adapter is available, try Chrome or Edge.' : 'WebGPU is unavailable. Use a browser with WebGPU (Chrome or Edge).'); + if (GPU.error) messages.push('WebGPU could not start: ' + GPU.error); const notice = $('cap-notice'); notice.replaceChildren(...messages.map(text => { const p = document.createElement('p'); p.textContent = text; return p; })); notice.hidden = !messages.length; } async function canStartGame() { await CAPS_CHECK; - return !REQUIRE_GPU_AND_THREADS || (CAPS.isolated && CAPS.webgpu); + return (!REQUIRE_WEBGPU || (CAPS.webgpu && GPU.ok)) && (!REQUIRE_THREADS || CAPS.isolated); } const CAPS_CHECK = checkCapabilities(); diff --git a/test/firefox_smoke.py b/test/firefox_smoke.py index d855081..fb001ad 100644 --- a/test/firefox_smoke.py +++ b/test/firefox_smoke.py @@ -26,13 +26,14 @@ with launch(browser_name='firefox') as (_, page, url): text = page.inner_text('#cap-notice') assert 'about:config' in text and 'dom.webgpu.enabled' in text and 'Firefox Nightly' in text print('PASS Firefox isolation and capability notice') - page.click('#btn-new') - page.fill('#nw-seed', '424242') - page.select_option('#nw-size', '2048x1024') - page.click('#btn-generate') - page.wait_for_function('GAME.running && !GAME.loading', timeout=60000) - before = page.evaluate('tick') - page.wait_for_timeout(3000) - assert page.evaluate('tick') > before - assert not errors, errors - print('PASS Firefox new world runs for three seconds without page errors') + if not caps['webgpu']: + assert not page.evaluate('canStartGame()') + assert not page.evaluate('startNewWorld(424242,512,384)') + assert not page.evaluate('GAME.running') + print('PASS Firefox without WebGPU cannot start a world') + else: + page.evaluate('startNewWorld(424242,512,384)') + page.wait_for_function('GAME.running && !GAME.loading',timeout=60000) + before=page.evaluate('tick');page.wait_for_timeout(3000) + assert page.evaluate('tick')>before + assert not errors,errors diff --git a/test/fixtures/golden/fire-pixel-12.png b/test/fixtures/golden/fire-pixel-12.png new file mode 100644 index 0000000..6f3b117 Binary files /dev/null and b/test/fixtures/golden/fire-pixel-12.png differ diff --git a/test/fixtures/golden/fire-pixel-2.png b/test/fixtures/golden/fire-pixel-2.png new file mode 100644 index 0000000..6557bb0 Binary files /dev/null and b/test/fixtures/golden/fire-pixel-2.png differ diff --git a/test/fixtures/golden/fire-smooth-12.png b/test/fixtures/golden/fire-smooth-12.png new file mode 100644 index 0000000..f587089 Binary files /dev/null and b/test/fixtures/golden/fire-smooth-12.png differ diff --git a/test/fixtures/golden/fire-smooth-2.png b/test/fixtures/golden/fire-smooth-2.png new file mode 100644 index 0000000..f876e8a Binary files /dev/null and b/test/fixtures/golden/fire-smooth-2.png differ diff --git a/test/fixtures/golden/joins-pixel-12.png b/test/fixtures/golden/joins-pixel-12.png new file mode 100644 index 0000000..4b9255d Binary files /dev/null and b/test/fixtures/golden/joins-pixel-12.png differ diff --git a/test/fixtures/golden/joins-pixel-2.png b/test/fixtures/golden/joins-pixel-2.png new file mode 100644 index 0000000..a74ce0e Binary files /dev/null and b/test/fixtures/golden/joins-pixel-2.png differ diff --git a/test/fixtures/golden/joins-smooth-12.png b/test/fixtures/golden/joins-smooth-12.png new file mode 100644 index 0000000..6f80e03 Binary files /dev/null and b/test/fixtures/golden/joins-smooth-12.png differ diff --git a/test/fixtures/golden/joins-smooth-2.png b/test/fixtures/golden/joins-smooth-2.png new file mode 100644 index 0000000..6beeea3 Binary files /dev/null and b/test/fixtures/golden/joins-smooth-2.png differ diff --git a/test/fixtures/golden/lamp-pixel-12.png b/test/fixtures/golden/lamp-pixel-12.png new file mode 100644 index 0000000..42b853f Binary files /dev/null and b/test/fixtures/golden/lamp-pixel-12.png differ diff --git a/test/fixtures/golden/lamp-pixel-2.png b/test/fixtures/golden/lamp-pixel-2.png new file mode 100644 index 0000000..0ee9d4b Binary files /dev/null and b/test/fixtures/golden/lamp-pixel-2.png differ diff --git a/test/fixtures/golden/lamp-smooth-12.png b/test/fixtures/golden/lamp-smooth-12.png new file mode 100644 index 0000000..2c6d72c Binary files /dev/null and b/test/fixtures/golden/lamp-smooth-12.png differ diff --git a/test/fixtures/golden/lamp-smooth-2.png b/test/fixtures/golden/lamp-smooth-2.png new file mode 100644 index 0000000..0945e85 Binary files /dev/null and b/test/fixtures/golden/lamp-smooth-2.png differ diff --git a/test/fixtures/golden/magma-pixel-12.png b/test/fixtures/golden/magma-pixel-12.png new file mode 100644 index 0000000..3fa4568 Binary files /dev/null and b/test/fixtures/golden/magma-pixel-12.png differ diff --git a/test/fixtures/golden/magma-pixel-2.png b/test/fixtures/golden/magma-pixel-2.png new file mode 100644 index 0000000..566d640 Binary files /dev/null and b/test/fixtures/golden/magma-pixel-2.png differ diff --git a/test/fixtures/golden/magma-smooth-12.png b/test/fixtures/golden/magma-smooth-12.png new file mode 100644 index 0000000..722df54 Binary files /dev/null and b/test/fixtures/golden/magma-smooth-12.png differ diff --git a/test/fixtures/golden/magma-smooth-2.png b/test/fixtures/golden/magma-smooth-2.png new file mode 100644 index 0000000..d5376ca Binary files /dev/null and b/test/fixtures/golden/magma-smooth-2.png differ diff --git a/test/fixtures/golden/opening-pixel-12.png b/test/fixtures/golden/opening-pixel-12.png new file mode 100644 index 0000000..d4de16b Binary files /dev/null and b/test/fixtures/golden/opening-pixel-12.png differ diff --git a/test/fixtures/golden/opening-pixel-2.png b/test/fixtures/golden/opening-pixel-2.png new file mode 100644 index 0000000..922a9d9 Binary files /dev/null and b/test/fixtures/golden/opening-pixel-2.png differ diff --git a/test/fixtures/golden/opening-smooth-12.png b/test/fixtures/golden/opening-smooth-12.png new file mode 100644 index 0000000..7c62fb4 Binary files /dev/null and b/test/fixtures/golden/opening-smooth-12.png differ diff --git a/test/fixtures/golden/opening-smooth-2.png b/test/fixtures/golden/opening-smooth-2.png new file mode 100644 index 0000000..2feabe5 Binary files /dev/null and b/test/fixtures/golden/opening-smooth-2.png differ diff --git a/test/fixtures/golden/renderer-pixel-12.png b/test/fixtures/golden/renderer-pixel-12.png new file mode 100644 index 0000000..567ad6a Binary files /dev/null and b/test/fixtures/golden/renderer-pixel-12.png differ diff --git a/test/fixtures/golden/renderer-pixel-2.png b/test/fixtures/golden/renderer-pixel-2.png new file mode 100644 index 0000000..e1a4512 Binary files /dev/null and b/test/fixtures/golden/renderer-pixel-2.png differ diff --git a/test/fixtures/golden/renderer-smooth-12.png b/test/fixtures/golden/renderer-smooth-12.png new file mode 100644 index 0000000..40663a6 Binary files /dev/null and b/test/fixtures/golden/renderer-smooth-12.png differ diff --git a/test/fixtures/golden/renderer-smooth-2.png b/test/fixtures/golden/renderer-smooth-2.png new file mode 100644 index 0000000..c30d489 Binary files /dev/null and b/test/fixtures/golden/renderer-smooth-2.png differ diff --git a/test/fixtures/golden/sky-pixel-12.png b/test/fixtures/golden/sky-pixel-12.png new file mode 100644 index 0000000..6079361 Binary files /dev/null and b/test/fixtures/golden/sky-pixel-12.png differ diff --git a/test/fixtures/golden/sky-pixel-2.png b/test/fixtures/golden/sky-pixel-2.png new file mode 100644 index 0000000..9b29ab9 Binary files /dev/null and b/test/fixtures/golden/sky-pixel-2.png differ diff --git a/test/fixtures/golden/sky-smooth-12.png b/test/fixtures/golden/sky-smooth-12.png new file mode 100644 index 0000000..0ffb3cd Binary files /dev/null and b/test/fixtures/golden/sky-smooth-12.png differ diff --git a/test/fixtures/golden/sky-smooth-2.png b/test/fixtures/golden/sky-smooth-2.png new file mode 100644 index 0000000..ce04884 Binary files /dev/null and b/test/fixtures/golden/sky-smooth-2.png differ diff --git a/test/t_light.js b/test/light-cases.js similarity index 53% rename from test/t_light.js rename to test/light-cases.js index c887f40..0f1ad47 100644 --- a/test/t_light.js +++ b/test/light-cases.js @@ -1,19 +1,6 @@ -// Run the renderer's lighting against real simulation data, with only canvas storage stubbed. -const fs = require('fs'); -const path = require('path'); -const { load, suite, flatWorld } = require('./harness'); -const { run } = load(); -const { check, done } = suite(); -for (const f of ['setup.js', 'light.js']) run(fs.readFileSync(path.join(__dirname, '../src/render', f), 'utf8')); -run(` - const SET = { lighting: 'full', daylight: 1, ambient: 0.12 }; - R.lightC = {}; - R.lightX = { createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), putImageData() {} }; - R.vx0 = 0; R.vy0 = 100; R.bw = 320; R.bh = 80; - function lightAt(a, x, y) { return a[(Math.floor(y / R.lres) - R.ly0) * R.lw + Math.floor(x / R.lres) - R.lx0]; } -`); -flatWorld(run, 320, 200, 160); -run(` +// Lighting behaviour checks executed by the GPU browser harness. +await flatWorld(run, 320, 200, 160); +await run(` bot.lamp = false; for (let bx = 8; bx < 30; bx++) placeBlock(BP_WALL, RSTONE, bx, 20, true); for (let by = 21; by < 39; by++) { @@ -26,43 +13,43 @@ run(` addMold('bit', 24, 30, RSTONE, true); for (let y = 80; y < 88; y++) for (let x = 180; x < 280; x++) setCell(y * W + x, STONE, AMB[y], F_NAT); updateRoof(); - const simRoof = Array.from(roofY); + window.simRoof = Array.from(roofY); `); for (const mode of ['full', 'fast']) { - run(`SET.lighting = '${mode}'; computeLight();`); + await run(`SET.lighting = '${mode}'; computeLight();`); for (const channel of ['lr', 'lg']) { - const [inside, open, rock, ground, openGround] = run(`[lightAt(R.${channel}, 80, 144), lightAt(R.${channel}, 152, 144), lightAt(R.${channel}, 232, 144), lightAt(R.${channel}, 80, 160), lightAt(R.${channel}, 152, 160)]`); + const [inside, open, rock, ground, openGround] = await run(`[lightAt(R.${channel}, 80, 144), lightAt(R.${channel}, 152, 144), lightAt(R.${channel}, 232, 144), lightAt(R.${channel}, 80, 160), lightAt(R.${channel}, 152, 160)]`); check(`${mode} ${channel}: under a built roof matches open daylight`, Math.abs(inside / open - 1) < 0.03, `${inside} / ${open}`); check(`${mode} ${channel}: ground under the base matches outside`, Math.abs(ground / openGround - 1) < 0.03, `${ground} / ${openGround}`); check(`${mode} ${channel}: natural overhang still shades`, rock < open * 0.95, `${rock} / ${open}`); } // Directly check transmission as daylight can hide an opaque piece in the open. for (const [name, x, y] of [['wall', 32, 120], ['belt', 48, 120], ['screw', 56, 120], ['machine', 72, 120], ['mold', 96, 120]]) { - const a = run(`lightAt(R.latt, ${x}, ${y})`); - check(`${mode}: ${name} transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-6, a); + const a = await run(`lightAt(R.latt, ${x}, ${y})`); + check(`${mode}: ${name} transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-3, a); } } -check('lighting leaves the simulation roof unchanged', run('simRoof.every((y, x) => roofY[x] === y) && roofY[80] === 80')); +check('lighting leaves the simulation roof unchanged', await run('simRoof.every((y, x) => roofY[x] === y) && roofY[80] === 80')); for (const material of ['STONE', 'SAND', 'WATER']) { - run(`setCell(96 * W + 80, ${material}, AMB[96], F_NAT); setCell(97 * W + 80, STONE, AMB[97], F_NAT); updateRoof(); computeLight();`); - check(`${material} above the view still blocks daylight`, run('lightAt(R.lr, 80, 144) < 0.95')); - run('setCell(96 * W + 80, EMPTY, AMB[96], 0); setCell(97 * W + 80, EMPTY, AMB[97], 0);'); + await run(`setCell(96 * W + 80, ${material}, AMB[96], F_NAT); setCell(97 * W + 80, STONE, AMB[97], F_NAT); updateRoof(); computeLight();`); + check(`${material} above the view still blocks daylight`, await run('lightAt(R.lr, 80, 144) < 0.925')); + await run('setCell(96 * W + 80, EMPTY, AMB[96], 0); setCell(97 * W + 80, EMPTY, AMB[97], 0);'); } -run('updateRoof(); computeLight();'); -check('removing an obstruction restores daylight', run('lightAt(R.lr, 80, 144) === 1')); +await run('updateRoof(); computeLight();'); +check('removing an obstruction restores daylight', await run('lightAt(R.lr, 80, 144) === 1')); for (const field of ['blockP[24 * BW + 20]', 'mach[96 * W + 80]', 'part[96 * W + 80]']) { - run(`setCell(96 * W + 80, STONE, AMB[96], F_NAT); ${field} = 1; updateRoof(); computeLight();`); - check(`${field}: built contents do not shade or absorb light`, run('lightAt(R.lr, 80, 144) === 1 && Math.abs(lightAt(R.latt, 80, 96) - Math.pow(0.978, 4)) < 1e-6')); - run(`${field} = 0; setCell(96 * W + 80, EMPTY, AMB[96], 0);`); + await run(`setCell(96 * W + 80, STONE, AMB[96], F_NAT); ${field} = 1; updateRoof(); computeLight();`); + check(`${field}: built contents do not shade or absorb light`, await run('lightAt(R.lr, 80, 144) === 1 && Math.abs(lightAt(R.latt, 80, 96) - Math.pow(0.978, 4)) < 1e-3')); + await run(`${field} = 0; setCell(96 * W + 80, EMPTY, AMB[96], 0);`); } -run('updateRoof();'); -run(`SET.daylight = 0; temp[140 * W + 80] = 1500; computeLight();`); -check('hot material inside the base lights its surroundings', run('lightAt(R.lr, 88, 140) > 0.2')); -run(`temp[140 * W + 80] = AMB[140]; bot.x = 72; bot.y = 130; bot.lamp = true; computeLight();`); -check('the bot lamp still lights the base', run('lightAt(R.lr, 80, 140) > 0.2')); +await run('updateRoof();'); +await run(`SET.daylight = 0; temp[140 * W + 80] = 1500; computeLight();`); +check('hot material inside the base lights its surroundings', await run('lightAt(R.lr, 88, 140) > 0.2')); +await run(`temp[140 * W + 80] = AMB[140]; bot.x = 72; bot.y = 130; bot.lamp = true; computeLight();`); +check('the bot lamp still lights the base', await run('lightAt(R.lr, 80, 140) > 0.2')); -flatWorld(run, 320, 200, 160); -run(` +await flatWorld(run, 320, 200, 160); +await run(` SET.daylight = 1; bot.lamp = false; for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 20, true); for (let x = 32; x < 120; x++) for (let y = 77; y < 80; y++) setCell(y * W + x, SAND, AMB[y], 0); @@ -74,19 +61,20 @@ run(` updateRoof(); `); for (const mode of ['full', 'fast']) { - run(`SET.lighting = '${mode}'; computeLight();`); + await run(`SET.lighting = '${mode}'; computeLight();`); for (const channel of ['lr', 'lg', 'lb']) { - const [belt, open, stone] = run(`[lightAt(R.${channel}, 80, 84), lightAt(R.${channel}, 160, 84), lightAt(R.${channel}, 232, 84)]`); - check(`${mode} ${channel}: a loaded belt leaves daylight below it`, Math.abs(belt - open) < 1e-6, `${belt} / ${open}`); + const [belt, open, stone] = await run(`[lightAt(R.${channel}, 80, 84), lightAt(R.${channel}, 160, 84), lightAt(R.${channel}, 232, 84)]`); + check(`${mode} ${channel}: a loaded belt leaves daylight below it`, Math.abs(belt - open) < 1e-3, `${belt} / ${open}`); check(`${mode} ${channel}: sand on natural stone still shades below`, stone < open * 0.95, `${stone} / ${open}`); - const [streamGround, openGround] = run(`[lightAt(R.${channel}, 152, 160), lightAt(R.${channel}, 160, 160)]`); - check(`${mode} ${channel}: a falling stream leaves ground as bright as open sky`, Math.abs(streamGround - openGround) < 1e-6, `${streamGround} / ${openGround}`); + const [streamGround, openGround] = await run(`[lightAt(R.${channel}, 152, 160), lightAt(R.${channel}, 160, 160)]`); + check(`${mode} ${channel}: a falling stream leaves ground as bright as open sky`, Math.abs(streamGround - openGround) < 1e-3, `${streamGround} / ${openGround}`); } - run('setCell(76 * W + 80, WATER, AMB[76], 0); updateRoof(); computeLight();'); - const a = run('lightAt(R.latt, 80, 76)'); - check(`${mode}: a wet belt load transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-6, a); - run('setCell(76 * W + 80, EMPTY, AMB[76], 0); updateRoof();'); + await run('setCell(76 * W + 80, WATER, AMB[76], 0); updateRoof(); computeLight();'); + const a = await run('lightAt(R.latt, 80, 76)'); + check(`${mode}: a wet belt load transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-3, a); + await run('setCell(76 * W + 80, EMPTY, AMB[76], 0); updateRoof();'); } +await run('R.camY=0;'); for (const [name, support, depth, gap, expected] of [ ['left belt', 'mat[i] = BELTL', 12, '', true], ['right belt', 'mat[i] = BELTR', 12, '', true], @@ -102,28 +90,29 @@ for (const [name, support, depth, gap, expected] of [ ['screw', 'part[i] = P_SCREW_R', 3, '', true], ['loaded screw', 'mat[i] = SAND; part[i] = P_SCREW_R', 3, '', true], ]) { - run(` + await run(` for (let y = 40; y <= 53; y++) { const i = y * W + 300; mat[i] = SAND; part[i] = 0; flags[i] = 0; } { const i = (40 + ${depth}) * W + 300; mat[i] = EMPTY; mat[i + W] = BELTR; ${support}; } ${gap ? `mat[41 * W + 300] = ${gap};` : ''} `); - check(`${name}: loose cell detection`, run('looseForLight(40 * W + 300)') === expected); + check(`${name}: loose cell detection`, await run('lightAt(R.latt,300,40) > 0.9') === expected); } -run('mat[40 * W + 300] = SAND; mat[41 * W + 300] = EMPTY;'); -check('a single grain in mid-air is see-through', run('looseForLight(40 * W + 300)')); -run('mat[(H - 1) * W + 300] = SAND;'); -check('loose cell detection stops at the world bottom', run('!looseForLight((H - 1) * W + 300)')); +await run('mat[40 * W + 300] = SAND; mat[41 * W + 300] = EMPTY;'); +check('a single grain in mid-air is see-through', await run('lightAt(R.latt,300,40) > 0.9')); +await run('R.camY=120;mat[(H - 1) * W + 300] = SAND;'); +check('loose cell detection stops at the world bottom', await run('lightAt(R.latt,300,H-1) < 0.92')); -flatWorld(run, 320, 200, 160); -run(` +await flatWorld(run, 320, 200, 160); +await run('R.camY=100;'); +await run(` bot.lamp = false; stats.refined = 0; placeBlock(BP_GRIND, 0, 20, 20, true); for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 36, true); setCell(79 * W + 80, SAND, AMB[79], 0); `); -check('sand resting on grinder housing is see-through', run('looseForLight(79 * W + 80)')); +check('sand resting on grinder housing is see-through', await run('lightAt(R.latt,80,79) > 0.9')); for (const mode of ['full', 'fast']) { - const result = run(`(() => { + const result = await run(`(() => { SET.lighting = '${mode}'; let mismatches = 0, first = ''; for (let t = 0; t < 180; t++) { @@ -142,5 +131,5 @@ for (const mode of ['full', 'fast']) { })()`); check(`${mode}: streaming grinder leaves ground in daylight for 180 consecutive ticks`, result.mismatches === 0, result.first || 'no shadow'); } -check('the streaming grinder actually processes its input', run('stats.refined > 0'), run('stats.refined')); +check('the streaming grinder actually processes its input', await run('stats.refined > 0'), await run('stats.refined')); done(); diff --git a/test/light-readback.js b/test/light-readback.js new file mode 100644 index 0000000..d625b58 --- /dev/null +++ b/test/light-readback.js @@ -0,0 +1,22 @@ +// Read float source channels, including transmittance, from the WGSL light pass. +function lightAt(a,x,y) { return a[(Math.floor(y/R.lres)-R.ly0)*R.lw+Math.floor(x/R.lres)-R.lx0]; } +async function testLight() { + if (!W) return; + renderDirty.fill(1);R.frame++;drawWorld(); + const d=GPU.device; + if(!window.testLightPipe) window.testLightPipe=await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code:` + @group(0) @binding(0) var source:texture_2d; + @group(0) @binding(1) var out:array; + @compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id:vec3u){ + let size=textureDimensions(source);let k=id.x;if(k>=size.x*size.y){return;} + out[k]=textureLoad(source,vec2i(i32(k%size.x),i32(k/size.x)),0); + }`}),entryPoint:'main'}}); + const bytes=R.lw*R.lh*16, storage=d.createBuffer({size:bytes,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_SRC}); + const read=d.createBuffer({size:bytes,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ}); + const bind=d.createBindGroup({layout:testLightPipe.getBindGroupLayout(0),entries:[{binding:0,resource:GPU.lightWork[0].createView()},{binding:1,resource:{buffer:storage}}]}); + const enc=d.createCommandEncoder(),pass=enc.beginComputePass();pass.setPipeline(testLightPipe);pass.setBindGroup(0,bind);pass.dispatchWorkgroups(Math.ceil(R.lw*R.lh/64));pass.end(); + enc.copyBufferToBuffer(storage,0,read,0,bytes);d.queue.submit([enc.finish()]);await read.mapAsync(GPUMapMode.READ); + const data=new Float32Array(read.getMappedRange()); + [R.lr,R.lg,R.lb,R.latt]=[0,1,2,3].map(c=>Float32Array.from({length:R.lw*R.lh},(_,i)=>data[i*4+c])); + read.unmap();read.destroy();storage.destroy(); +} diff --git a/test/play.py b/test/play.py index 349b451..f4d92b9 100644 --- a/test/play.py +++ b/test/play.py @@ -173,7 +173,7 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url): check('but not on the title screen', not ev(f'(() => {{ GAME.running = false; const r = {leave}; GAME.running = true; return r; }})()')) ev("SET.zoom = 4; resizeRender(); R.snapNext = true;") # the rest of this play-through was laid out at zoom 4 ev(f"SET.look = '{look}'"); pg.wait_for_timeout(200) - check(f'the {look} look is drawing', ev('R.smoothOn') == (look == 'smooth'), ev('[SET.look, R.smoothOn, SM.ok]')) + check(f'the {look} look is drawing', ev('R.smoothOn') == (look == 'smooth'), ev('[SET.look, R.smoothOn, GPU.ok]')) # Maps use the real frame loop and keyboard, including settings persistence. pg.wait_for_function('MAP.image && MAP.init >= Math.ceil(W / 32) * Math.ceil(H / 32)') check('minimap lives in bottom right with its controls', ev("!!document.querySelector('#hud .br #minimap canvas') && ['mini-plus', 'mini-minus', 'mini-map'].every(id => $(id).tagName === 'BUTTON')")) @@ -1220,8 +1220,6 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url): # Fog has its own paused scene; existing play checks keep the whole world visible. fog_context = b.new_context(viewport={'width': 1280, 'height': 800}) fp = fog_context.new_page() - if pg.evaluate('!!window.__allowSoftwareGL'): - fp.add_init_script('window.__allowSoftwareGL = true') fp.on('pageerror', lambda e: logs.append(f'PAGEERROR(fog): {e}')) fp.goto(url) fp.evaluate('startNewWorld(4242, 512, 384)') @@ -1328,12 +1326,11 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url): check('fog toggle shows the same rock', fp.evaluate(sample) != 0) fp.evaluate("SET.fog = true; SET.look = 'smooth'; SM.t0 = performance.now() + 100000") fp.wait_for_timeout(500) - check('fog draws in the smooth look', fp.evaluate('R.smoothOn && (R.gpuOn ? GPU.ok : SM.ok)')) + check('fog draws in the smooth look', fp.evaluate('R.smoothOn && GPU.ok')) fp.add_script_tag(path=str(root / 'test/gpu-readback.js')) check('unseen smooth grains have no terrain shape', fp.evaluate("""async () => { const k=(235-R.vy0)*R.bw+300-R.vx0; - const inputs=R.gpuOn ? Array.from((await testSmoothInputs()).slice(k*4,k*4+4)) : - [R.px[k],R.backPx[k],R.treePx[k],R.fieldPx[k]]; + const inputs=Array.from((await testSmoothInputs()).slice(k*4,k*4+4)); return inputs[0]===0x04000000 && inputs[1]===0 && inputs[2]===0 && inputs[3]===255; }""")) fp.screenshot(path=str(out / 'fog-smooth.png')) diff --git a/test/run_all.py b/test/run_all.py index c07baaf..ebb06ab 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', 't_gpu.py', 'firefox_smoke.py']: + for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']: results.append(run_py(name)) bad = 0 for name, code, out, secs in results: diff --git a/test/t_caps.py b/test/t_caps.py index 435fdc6..f3c80b8 100644 --- a/test/t_caps.py +++ b/test/t_caps.py @@ -1,4 +1,4 @@ -"""Capability detection, title warnings and the future startup gate.""" +"""Capability detection, title notices and the required-WebGPU startup gate.""" from pathlib import Path import sys @@ -8,23 +8,18 @@ ensure_playwright() from browser import capabilities, consume_gpu_option, launch consume_gpu_option() -for case in ['native', 'absent', 'null', 'reject', 'firefox', 'required']: +for case in ['native', 'absent', 'null', 'reject', 'firefox', 'limits']: with launch() as (_, page, url): errors = [] page.on('pageerror', lambda e: errors.append(str(e))) if case != 'native': gpu = 'undefined' if case == 'absent' else "{requestAdapter: async () => null}" if case == 'reject': gpu = "{requestAdapter: async () => {throw new Error('adapter denied')}}" + if case == 'limits': gpu="{requestAdapter:async()=>({limits:{maxStorageTexturesPerShaderStage:3}})}" page.add_init_script(f"Object.defineProperty(navigator, 'gpu', {{value: {gpu}}});") page.add_init_script("Object.defineProperty(self, 'crossOriginIsolated', {value: false});") if case == 'firefox': page.add_init_script("Object.defineProperty(navigator, 'userAgent', {value: 'Firefox/157.0'});") - if case == 'required': - def require(route): - response = route.fetch() - route.fulfill(response=response, body=response.text().replace( - 'const REQUIRE_GPU_AND_THREADS = false;', 'const REQUIRE_GPU_AND_THREADS = true;')) - page.route('**/index.html', require) page.goto(url) caps = capabilities(page) page.wait_for_function("!$('title').hidden") @@ -36,17 +31,13 @@ for case in ['native', 'absent', 'null', 'reject', 'firefox', 'required']: notice = page.inner_text('#cap-notice') assert 'python3 tools/serve.py' in notice and "opened from a file it can't" in notice assert ('Firefox Nightly' in notice and 'dom.webgpu.enabled' in notice) if case == 'firefox' else 'Chrome or Edge' in notice - if case == 'required': + if case != 'native': assert not page.evaluate('canStartGame()') page.evaluate('startNewWorld(424242, 512, 384)') assert not page.evaluate('GAME.running || GAME.loading') assert not page.evaluate('startFromSave({})') - else: - assert page.evaluate('canStartGame()') if case == 'absent': Path('shots').mkdir(exist_ok=True) page.screenshot(path='shots/t88-p1-notice.png') - page.evaluate("SET.look='pixel'; startNewWorld(424242, 512, 384)") - page.wait_for_function('GAME.running && !GAME.loading', timeout=60000) assert not errors, errors print(f'PASS capabilities: {case}') diff --git a/test/t_gpu.py b/test/t_gpu.py index 2e09ea3..9a5003c 100644 --- a/test/t_gpu.py +++ b/test/t_gpu.py @@ -1,159 +1,125 @@ -"""WebGPU pixel parity, rare-cell selection, world replacement, resize and device loss.""" +"""Dirty uploads, default limits, warm pipelines, world replacement and device recovery.""" import pathlib, sys -sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools')) +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; +consume_gpu_option() +root=pathlib.Path(__file__).resolve().parent.parent +with launch(viewport={'width':960,'height':600}) as (_,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') + assert pg.evaluate('GPU.ok'),pg.evaluate('GPU.error') + assert pg.evaluate('GPU.device.limits.maxStorageTexturesPerShaderStage===4') + print('PASS all pipelines compile with default limits',pg.evaluate('GPU_LIMITS')) + startup=pg.evaluate("""async()=>{ + await startNewWorld(424242,640,384); GAME.paused=true; + const samples=[]; + for(let n=0;n<10;n++) {const t=performance.now();drawWorld();await GPU.device.queue.onSubmittedWorkDone();samples.push(performance.now()-t);} + return {compile:GPU.initMs,warm:GPU.warmMs,first:samples[0],normal:samples.slice(1)}; + }""") + assert startup['warm']>=0 + print('PASS both looks submit behind loading screen; first visible frame ms',startup) + pg.add_script_tag(path=str(root/'tools/perf-scenes.js')) + pg.evaluate("perfScene('factory',1024,640); SET.look='pixel'; SET.zoom=2;resizeRender();snapCamera();drawWorld();") + result=pg.evaluate('''async () => { 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 uses WebGPU') - pg.evaluate('() => {' + (root / 'tools/scenes/t88-renderer.js').read_text() + '}') - pg.add_script_tag(path=str(root / 'test/gpu-readback.js')) - # Compare all four smooth inputs, including valid zero tags and class-zero overrides. - smooth_inputs = pg.evaluate("""async () => { - // Every material also gets a plain, unowned cell (including part materials). - for(let m=0;m>2)*BW+(x>>2)]=0; - } - const results=[]; - const now=performance.now.bind(performance); performance.now=()=>5000; SM.t0=0; - try { - for(const zoom of [12,6,2])for(const fogOn of [false,true]) { - SET.zoom=zoom;SET.fog=fogOn;SET.look='smooth';R.lens=false;SET.lighting='off';SET.glow='off'; - resizeRender();bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2;snapCamera();R.frame=999; - SET.renderer='legacy';SM.scale=1;drawWorld(); - const expected=[R.px.slice(),R.backPx.slice(),R.treePx.slice(),R.fieldPx.slice()]; - SET.renderer='webgpu';drawWorld(); - const actual=await testSmoothInputs(); let alpha=0,max=0,bad=0,examples=[]; - for(let k=0;k>>24)!==(b>>>24))alpha++; - for(let c=0;c<3;c++){const diff=Math.abs((a>>>(c*8)&255)-(b>>>(c*8)&255));max=Math.max(max,diff);if(diff>1){bad++;if(examples.length<3)examples.push({k,t,a,b});}} + for(let n=0;n<240;n++) { + simTick(); + if(n%20===0) { + fogLook(bot.x,bot.y); R.camX=(n*7)%400; R.camY=(n*3)%280; + SET.look=n%40?'smooth':'pixel';R.frame++;drawWorld(); + results.push(await gpuCheckUploads()); } - results.push({zoom,fogOn,alpha,max,bad,examples,share:GPU.overrideCount/R.px.length}); } - return results; - } finally { performance.now=now; } + drawWorld();await gpuCheckUploads(); + drawWorld();return {checked:results,cleanCells:GPU.uploadCells}; + }''') + assert min(result['checked'])>1000 and result['cleanCells']==0,result + print('PASS busy ticks, heat, fog, scrolling, both looks: GPU bytes equal full CPU data; clean frame uploads no world cells',result) + parity=pg.evaluate("""async()=>{ + const results=[]; + const now=performance.now.bind(performance),frozen=now();performance.now=()=>frozen; + try { + for(const look of ['pixel','smooth'])for(const light of ['full','fast','off'])for(const lens of [false,true])for(const fogOn of [false,true]) { + SET.look=look;SET.lighting=light;R.lens=lens;SET.fog=fogOn;R.frame++; + R.rain=0;drawWorld();await GPU.device.queue.onSubmittedWorkDone(); + const a=worldPixelContext().getImageData(0,0,960,600).data; + GPU.resident.fill(0);R.rain=0;drawWorld();await GPU.device.queue.onSubmittedWorkDone(); + const b=worldPixelContext().getImageData(0,0,960,600).data; + let max=0;for(let i=0;i=0') - assert pg.evaluate('perfMarks.override>=0 && perfMarks.upload>=0') - print('PASS separate override/upload clocks and optional GPU timestamps') - pg.evaluate('() => {' + (root / 'tools/scenes/t88-light.js').read_text() + '}') - # Draw GPU first with no CPU grid and forbid accidental CPU lighting calls. - pg.evaluate("""() => { - window.savedComputeLight=computeLight; - computeLight=()=>{throw Error('CPU light called in WebGPU');}; - R.lr=R.lg=R.lb=R.latt=R.lightPx=null; R.lw=R.lh=0; - SET.renderer='webgpu';SET.look='pixel';SET.zoom=6;SET.lighting='full';SET.lightEvery=1; - resizeRender();bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2;snapCamera();drawWorld(); - computeLight=savedComputeLight; - }""") - for mode in ['full', 'fast']: - for case in ['lamp', 'opening', 'magma', 'fire']: - parity = pg.evaluate("""([mode,kind]) => { - lightScene(kind);SET.lighting=mode;SET.lightEvery=1;R.frame=1000;R.rain=0; - SET.renderer='legacy';drawWorld();const a=worldPixelContext().getImageData(0,0,960,600).data; - R.rain=0;SET.renderer='webgpu';drawWorld();const b=worldPixelContext().getImageData(0,0,960,600).data; - let max=0,bad=0,sum=0; - for(let i=0;i8;} - return {mode,kind,max,bad,mean:sum/(960*600*3)}; - }""", [mode,case]) - assert parity['bad'] == 0, parity - print('PASS light scene', parity) - cadence = pg.evaluate("""() => { + assert all(r['max']==0 for r in parity),parity + print('PASS dirty and forced full uploads render identically in',len(parity),'look/light/lens/fog configurations') + cadence=pg.evaluate("""()=>{ SET.lightEvery=3;R.frame=1002;drawWorld();const old=GPU.lightFrame,origin=R.lx0; - bot.lamp=!bot.lamp;R.camX+=4;R.frame=1003;drawWorld(); - const held=GPU.lightFrame===old && R.lx0===origin; + R.camX+=4;R.frame=1003;drawWorld();const held=GPU.lightFrame===old && R.lx0===origin; R.frame=1005;drawWorld();const updated=GPU.lightFrame===1005 && R.lx0!==origin; SET.lightEvery=1;return {held,updated}; }""") - assert all(cadence.values()), cadence - print('PASS GPU-only lighting, both resolutions and lightEvery cadence') - switches = pg.evaluate("""() => { - const original=computeLight,frames=[]; - computeLight=()=>{throw Error('CPU light called in WebGPU smooth');}; - try { - SET.lightEvery=100; - for(const look of ['smooth','pixel','smooth']) { - SET.look=look;R.frame++;drawWorld(); - frames.push(R.gpuOn && GPU.lightFrame===R.frame && R.smoothOn===(look==='smooth')); - } - return frames; - } finally {computeLight=original;SET.lightEvery=1;} + assert all(cadence.values()),cadence + print('PASS lighting cadence holds and then updates its camera origin') + # Force paths that can mutate sleeping chunks, away from the busy factory. + pg.evaluate('''() => { + const i=500*W+800; setCell(i,STONE,20,F_NAT);digp[i]=120;renderDirtyIdx(i); + setTreeCell(801,450,WOOD);fogRevealAll(); + R.camX=650;R.camY=400;drawWorld(); + }''') + assert pg.evaluate('gpuCheckUploads()')>1000 + pg.evaluate('generateWorld(777,640,384,null);buildSky();resizeRender();snapCamera();drawWorld();') + assert pg.evaluate('GPU.world===mat && GPU.ok') + assert pg.evaluate('gpuCheckUploads()')>1000 + print('PASS world replacement refreshes resident chunks and static data') + assert pg.evaluate('R.ctx.getImageData(10,10,1,1).data[3]===0 && worldPixelContext().getImageData(10,10,1,1).data[3]===255') + print('PASS GPU world stays opaque under transparent overlays') + cracks=pg.evaluate("""async()=>{ + SET.zoom=12;SET.look='pixel';SET.fog=false;SET.lighting='off';SET.glow='off';SET.grain=0; + resizeRender();R.camX=200;R.camY=160; + for(let y=170;y<192;y++)for(let x=215;x<260;x++)setCell(y*W+x,STONE,20,F_NAT); + drawWorld();const clean=worldPixelContext().getImageData(0,0,960,600).data; + for(let y=170;y<192;y++)for(let x=215;x<260;x++){digp[y*W+x]=240;renderDirtyXY(x,y);} + R.frame=1000;drawWorld();await GPU.device.queue.onSubmittedWorkDone(); + const damaged=worldPixelContext().getImageData(0,0,960,600).data; + let changed=0;for(let i=0;i=0') - pg.evaluate('GPU.device.destroy()') - pg.wait_for_function('!GPU.ok', polling=10) - assert pg.evaluate('SET.renderer==="legacy"') + assert cracks>1000,cracks + pg.screenshot(path=str(root/'shots/t88-drill-pixel.png')) + pg.evaluate("SET.look='smooth';drawWorld()") + pg.screenshot(path=str(root/'shots/t88-drill-smooth.png')) + print('PASS stationary drill damage changes GPU colour in both looks',cracks) + timing=pg.evaluate('''async () => { + const originalC=GPU.device.createComputePipeline,originalR=GPU.device.createRenderPipeline; + GPU.device.createComputePipeline=GPU.device.createRenderPipeline=()=>{throw Error('synchronous pipeline compilation');}; + const samples=[]; + try { + for(const [look,zoom] of [['pixel',12],['smooth',12],['smooth',2],['pixel',2],['smooth',6]]) { + SET.look=look;SET.zoom=zoom;resizeRender();snapCamera();const start=performance.now();drawWorld(); + await GPU.device.queue.onSubmittedWorkDone();samples.push({look,zoom,first:performance.now()-start}); + const times=[];for(let n=0;n<8;n++){const t=performance.now();drawWorld();await GPU.device.queue.onSubmittedWorkDone();times.push(performance.now()-t);} + samples.at(-1).normal=Math.max(...times); + } + } finally {GPU.device.createComputePipeline=originalC;GPU.device.createRenderPipeline=originalR;} + return {init:GPU.initMs,samples}; + }''') + print('PASS look and zoom switches create no pipelines; timings ms',timing) + # Successful recovery must recreate resources and preserve simulation arrays. + pg.evaluate('window.savedMat=mat; GPU.device.destroy();') + pg.wait_for_function('GPU.ok && !GPU.recovering',polling=20,timeout=60000) + assert pg.evaluate('mat===savedMat') pg.evaluate('drawWorld()') - print('PASS device loss restores the legacy renderer') - assert not errors, errors + assert pg.evaluate('gpuCheckUploads()')>1000 + print('PASS device loss recreates the device once without changing the world') + pg.evaluate("Object.defineProperty(navigator,'gpu',{value:{requestAdapter:async()=>null}});GPU.device.destroy();") + pg.wait_for_function('GPU.stopped',polling=20,timeout=60000) + assert pg.evaluate('GAME.paused') and 'Reload' in pg.inner_text('#m-dialog') + assert pg.evaluate("DB.get(GAME.worldId+':auto').then(s=>!!s)") + print('PASS failed recovery stops frames, autosaves and offers Reload') + assert not errors,errors print('PASS no page or WebGPU validation errors') diff --git a/test/t_gpu_golden.py b/test/t_gpu_golden.py new file mode 100644 index 0000000..6566279 --- /dev/null +++ b/test/t_gpu_golden.py @@ -0,0 +1,48 @@ +"""Frozen legacy references, captured before retirement; compare the WebGPU world only.""" +import base64, pathlib, sys, subprocess +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 +consume_gpu_option() +ROOT = pathlib.Path(__file__).resolve().parent.parent +GOLD = ROOT / 'test/fixtures/golden' +with launch(viewport={'width': 640, 'height': 480}) as (_, 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') + assert pg.evaluate('GPU.ok'), pg.evaluate('GPU.error') + for scene in ['renderer','joins','sky','lamp','opening','magma','fire']: + for look in ['pixel','smooth']: + for zoom in [12,2]: + pg.evaluate('(h)=>{generateWorld(424242,640,h,null);enterGame();GAME.paused=true;}',512 if scene=='joins' else 384) + name = 'joins' if scene=='joins' else 't88-renderer' if scene in ['renderer','sky'] else 't88-light' + pg.evaluate('() => {'+(ROOT / f'tools/scenes/{name}.js').read_text()+'}') + if scene not in ['renderer','joins','sky']: + pg.evaluate('s=>lightScene(s)',scene) + if scene=='joins': + pg.evaluate('SET.fog=false; CAM=[280,98];') + if scene=='sky': + pg.evaluate('CAM=[320,55]; SET.fog=false;') + pg.evaluate("""([look,zoom])=>{ + SET.look=look;SET.zoom=zoom;SET.lighting='full';SET.lightEvery=1;SET.glow='low'; + SM.t0=0; SM.surf=-1; + performance.now=()=>5000;resizeRender();bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2; + snapCamera();R.frame=1000;drawWorld(); + }""",[look,zoom]) + raw=pg.evaluate("""() => { + const c=document.createElement('canvas');c.width=384;c.height=288; + c.getContext('2d').drawImage(worldPixelContext().canvas,128,96,384,288,0,0,384,288); + return c.toDataURL().split(',')[1]; + }""") + path=GOLD/f'{scene}-{look}-{zoom}.png' + actual=base64.b64decode(raw) + rgb=lambda data: subprocess.run(['magick','png:-','-alpha','off','-depth','8','rgb:-'],input=data,capture_output=True,check=True).stdout + a,b=rgb(actual),rgb(path.read_bytes()) + ds=[abs(x-y) for x,y in zip(a,b)] + mean=sum(ds)/len(ds);bad=sum(max(ds[i:i+3])>8 for i in range(0,len(ds),3)) + assert mean<0.5 and bad<384*288*0.003, (path.name,mean,bad) + print('PASS golden',path.name,round(mean,4),bad) + (ROOT/'shots'/('golden-'+path.name)).write_bytes(actual) + assert not errors,errors diff --git a/test/t_gpu_light.py b/test/t_gpu_light.py new file mode 100644 index 0000000..110e64b --- /dev/null +++ b/test/t_gpu_light.py @@ -0,0 +1,43 @@ +"""The former CPU light behavioural checks, now read from the actual GPU light grid.""" +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 +consume_gpu_option() +root=pathlib.Path(__file__).resolve().parent.parent +with launch(viewport={'width':636,'height':156}) as (_,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') + pg.add_script_tag(path=str(root/'test/light-readback.js')) + source=(root/'test/light-cases.js').read_text() + result=pg.evaluate("""async source=>{ + const results=[]; + function check(name,ok,detail){results.push({name,ok:!!ok,detail});} + function done(){} + async function run(code){ + code=code.replaceAll('computeLight();','await testLight();'); + let value; + if(code.includes('await ')) { + if(code.trim().startsWith('(() =>')) code=code.replace('(() =>','(async () =>'); + else code='(async()=>{'+code+'})()'; + } + value=await (0,eval)(code); + if(!code.includes('testLight()')) await testLight(); + return value; + } + async function flatWorld(run,w,h,g){ + await run(`alloc(${w},${h});worldSeed=7;for(let x=0;x=${g}?STONE:EMPTY;flags[i]=y>=${g}?F_NAT:0;temp[i]=AMB[y];} + bot.x=(W>>1)-8;bot.y=${g}-30;bag=new Int32Array(NMAT);bagTotal=0;player.tankTier=3;updateRoof();resetWeather();weather.next=1e15;buildSky();`); + } + SET.zoom=2;SET.look='pixel';SET.fog=false;SET.glow='off';SET.daylight=1;SET.ambient=0.12;SET.lightEvery=1; + initRender(canvas);resizeRender();R.camX=0;R.camY=100; + return await eval('(async()=>{'+source+';return results;})()'); + }""",source) + for r in result: + print(('PASS ' if r['ok'] else 'FAIL ')+r['name']+' '+str(r.get('detail',''))) + assert all(r['ok'] for r in result) + assert not errors,errors diff --git a/test/t_render_dirty.js b/test/t_render_dirty.js new file mode 100644 index 0000000..56b64fd --- /dev/null +++ b/test/t_render_dirty.js @@ -0,0 +1,18 @@ +// Render invalidation must not depend on whether a grain moved or the chunk stayed awake. +const {load,suite,flatWorld}=require('./harness'); +const {run}=load(),{check,done}=suite(); +flatWorld(run,128,128,100); +function marked(code,x,y){run('renderDirty.fill(0);'+code);return run(`!!renderDirty[(${y}>>5)*CW+(${x}>>5)]`);} +run('setCell(65*W+65,STONE,20,F_NAT);bot.x=63;bot.y=60;'); +check('stationary drill damage invalidates the rock',marked('toolDig(65,65,0);',65,65) && run('digp[65*W+65]>0 && mat[65*W+65]===STONE')); +check('waking a cell invalidates its render chunk',marked('wakeXY(31,31);',31,31)); +check('swapping cells invalidates both render chunks',marked('swapCells(10*W+10,70*W+70);',70,70)&&run('!!renderDirty[0]')); +check('exploration invalidates its render chunk',marked('fogMark(70,70,255);',70,70)); +check('revealing the whole map invalidates every chunk',marked('fogRevealAll();',70,70)&&run('renderDirty.every(v=>v===1)')); +check('heat invalidates the chunk and conduction across its edges',marked('heatChunk(5,1);',64,40)&&run('!!renderDirty[1] && !!renderDirty[4] && !!renderDirty[9]')); +check('tree growth invalidates the background',marked('setTreeCell(70,70,WOOD);',70,70)); +run('setCell(60*W+60,FIRE,900,0);life[60*W+60]=10;for(const j of [60*W+59,60*W+61,59*W+60,61*W+60])setCell(j,STONE,20,F_NAT);'); +check('blocked fire aging invalidates without movement',marked('gas(60*W+60,60,60,FIRE);',60,60) && run('life[60*W+60]===9')); +run('setCell(32*W+64,SAND,20,0);part[32*W+64]=P_GOPEN;setCell(31*W+64,EMPTY,20,0);'); +check('closing a gate invalidates the displaced grain across a chunk edge',marked('toggleGateAt(64,32);',64,31) && run('mat[31*W+64]===SAND')); +done(); diff --git a/test/t_smooth.py b/test/t_smooth.py index eb36c1c..8d849c7 100644 --- a/test/t_smooth.py +++ b/test/t_smooth.py @@ -1,4 +1,4 @@ -"""The smooth look: the WebGPU pass compiles, draws, falls back cleanly, and the thermal lens still works.""" +"""The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works.""" import sys, pathlib sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools')) from use_venv import ensure_playwright; ensure_playwright() @@ -19,7 +19,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate') pg.wait_for_function('GAME.running === true', timeout=20000); pg.wait_for_timeout(1500) ev = pg.evaluate - check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && GPU.ok && R.gpuOn && R.smoothOn"), ev('[SET.look, SM.ok, SM.why, R.smoothOn]')) + check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && GPU.ok && R.gpuOn && R.smoothOn"), ev('[SET.look, GPU.ok, GPU.error, 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 = 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) @@ -27,15 +27,11 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): ev("player.lens = true"); pg.keyboard.press('h'); pg.wait_for_timeout(1500) check('the thermal lens draws in pixels', ev('R.lens && !R.smoothOn')) pg.keyboard.press('h'); pg.wait_for_timeout(1500) - check('and the smooth look comes back after it', ev('!R.lens && R.smoothOn'), ev('[R.lens, R.smoothOn, SET.look, SM.ok, SM.why, SM.ms, SM.scale]')) + check('and the smooth look comes back after it', ev('!R.lens && R.smoothOn'), ev('[R.lens, R.smoothOn, SET.look, GPU.ok, GPU.error]')) ev("SET.look = 'pixel'"); pg.wait_for_timeout(1500) check('the pixel look can be chosen', ev('!R.smoothOn')) pg.screenshot(path=str(out / 'pixel.png')) ev("SET.look = 'smooth'"); pg.wait_for_timeout(1500) - ev("SET.renderer = 'legacy'; SM.scale = 0.5"); pg.wait_for_timeout(1500) - st = ev('[R.smoothOn, SM.scale, SM.canvas.width, R.canvas.width, SET.look, SM.ok, SM.why]'); check('half-resolution smooth still draws', (st[0] and st[2] == round(st[3] * st[1])) or st[1] > 0.5, st) - ev("SET.renderer = 'webgpu'"); pg.wait_for_timeout(300) - check('smooth returns to WebGPU after legacy comparison', ev('R.gpuOn && R.smoothOn')) # Uniform colours isolate the solid/loose seam from material noise and lighting. ground = ev('''() => { GAME.paused = true; requestAnimationFrame = () => 0; @@ -50,16 +46,16 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): 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(); + R.lens = lens; SET.look = 'pixel'; fill(RSTONE); renderDirty.fill(1); GPU.world=null; drawWorld(); const edge = at(140, 140), inside = at(140, 142), loose = at(140, 130); - fill(STONE); drawWorld(); + fill(STONE); renderDirty.fill(1); GPU.world=null; drawWorld(); out[lens ? 'thermal' : 'pixel'] = [edge, inside, loose, at(140, 140)]; } R.lens = false; const i = 140 * W + 140; - flags[i] = 0; fill(RSTONE); drawWorld(); const built = at(140, 140); - fill(STONE); drawWorld(); out.built = [built, at(140, 140)]; flags[i] = F_NAT; - fill(RSTONE); SET.look = 'smooth'; SM.scale = 1; drawWorld(); + flags[i] = 0; fill(RSTONE); renderDirty.fill(1); GPU.world=null; drawWorld(); const built = at(140, 140); + fill(STONE); renderDirty.fill(1); GPU.world=null; drawWorld(); out.built = [built, at(140, 140)]; flags[i] = F_NAT; + fill(RSTONE); SET.look = 'smooth'; renderDirty.fill(1); GPU.world=null; drawWorld(); const sample = (x, y) => { const p = worldToScreenPx(x, y); const c = worldPixelContext().getImageData(Math.floor(p.x), Math.floor(p.y), 1, 1).data; @@ -125,8 +121,8 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): check('off hides both label and vein outline', not cursor['off'] and cursor['offStrokes'] == 0) for look in ['pixel', 'smooth']: ev("""look => { - SET.look=look; SM.scale=1; SET.cursorLabel='ores'; GAME.aim={x:124,y:122}; cursorLabelKey=null; - $('cards').replaceChildren(); renderInfo(); drawWorld(); drawHoveredVein(); drawBot(); + SET.look=look; SET.cursorLabel='ores'; GAME.aim={x:124,y:122}; cursorLabelKey=null; + $('cards').replaceChildren(); renderInfo(); renderDirty.fill(1); GPU.world=null; drawWorld(); drawHoveredVein(); drawBot(); const p=worldToScreenPx(124,122); drawCursorLabel(p.x,p.y,1); }""", look) pg.screenshot(path=str(root / 'shots' / ('t77-vein.png' if look == 'pixel' else 't77-vein-smooth.png'))) @@ -145,8 +141,8 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): setCell(cell,SAND,22,0);setCell(control,SAND,22,0); 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; + SET.look='pixel';renderDirty.fill(1); GPU.world=null; drawWorld();const colors=[at(cell)&0xffffff,at(control)&0xffffff]; + SET.look='smooth';renderDirty.fill(1); GPU.world=null; drawWorld();const grainClass=at(cell)>>>24; return {colors,grainClass}; }''') check('sidewall stays behind the grain colour in pixels and smooth', @@ -163,7 +159,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): try { const out={}; for(const look of ["pixel","smooth"]) { - SET.look=look;SM.scale=1;drawWorld(); + SET.look=look;renderDirty.fill(1); GPU.world=null; 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(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)) { @@ -215,9 +211,9 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): 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(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); + renderDirty.fill(1); GPU.world=null; drawWorld();const bg=sample(ux,uy),empty=sample(2,2); for(const [n,piece] of plan)ghostBlocks.set(n,piece); - drawWorld(); + renderDirty.fill(1); GPU.world=null; drawWorld(); if(sample(ux,uy)!==bg || sample(2,2)===empty)bad.push(['ghost elbow silhouette',look,j]); ghostBlocks.clear(); const ctx=R.ctx,canvas=document.createElement('canvas');canvas.width=canvas.height=192; @@ -234,16 +230,16 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): }''') check('both looks: ghosts and planned previews leave the unused elbow corner empty', not previews, previews[:2]) tree_tags = ev('''() => { - SET.look='smooth';SM.scale=1; + SET.look='smooth'; const now=performance.now.bind(performance),frozen=now();performance.now=()=>frozen; 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(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),4*R.zp,4*R.zp).data).join(',');}; - drawWorld();const plain=sample(),bad=[]; + renderDirty.fill(1); GPU.world=null; drawWorld();const plain=sample(),bad=[]; for(const m of [WOOD,LEAVES]) { for(let y=by*4;y0;performance.now=()=>frozen; 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(); + SET.look=look;renderDirty.fill(1); GPU.world=null; drawWorld();const joined=sample(); + removeBlock(j.nx,j.ny);renderDirty.fill(1); GPU.world=null; drawWorld();const free=sample(); placeBlock(BP_BELT,j.straight,j.nx,j.ny,true); out[look]=[joined,free]; } @@ -289,12 +285,5 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url): check('no errors', not errs, errs[:3]) b.close() -# a browser without hardware drawing gets the pixel look and a note -with launch(gpu='soft', viewport={'width': 800, 'height': 500}) as (_, pg2, soft_url): - pg2.add_init_script('window.__allowSoftwareGL = false; localStorage.clear();') - pg2.goto(soft_url); pg2.evaluate("SET.fog = false; SET.renderer='legacy'"); pg2.wait_for_timeout(300) - pg2.click('#btn-new'); pg2.fill('#nw-seed', '5'); pg2.click('#btn-generate') - pg2.wait_for_function('GAME.running === true', timeout=20000); pg2.wait_for_timeout(1200) - check('software rendering falls back to pixels with a note', pg2.evaluate("SET.look === 'pixel' && !R.smoothOn && SM.why === 'software'") and pg2.locator('.toast', has_text='graphics card').count() >= 1, pg2.evaluate('[SET.look, SM.why]')) print('FAILS:', fails) sys.exit(1 if fails else 0) diff --git a/tools/browser.py b/tools/browser.py index 54bfc7a..2b39de4 100644 --- a/tools/browser.py +++ b/tools/browser.py @@ -42,8 +42,6 @@ def launch(*, gpu=None, browser_name='chromium', viewport=None, page_path=None, try: context = browser.new_context(viewport=viewport or {'width': 1280, 'height': 760}) page = context.new_page() - if gpu == 'soft': - page.add_init_script('window.__allowSoftwareGL = true;') yield browser, page, base + '/' + quote(path.name) finally: browser.close() diff --git a/tools/perf-measure.js b/tools/perf-measure.js index 517b3ae..b027c83 100644 --- a/tools/perf-measure.js +++ b/tools/perf-measure.js @@ -41,13 +41,7 @@ async function perfRender(lighting, overlays) { draw += perfLast.drawWorld / n; for (const [key, value] of Object.entries(perfMarks)) marks[key] = (marks[key] || 0) + value / n; } - let light = 0; - if (!R.gpuOn) { - for (let k = 0; k < 5; k++) computeLight(); - const l0 = performance.now(); - for (let k = 0; k < n; k++) computeLight(); - light = (performance.now() - l0) / n; - } + const light = 0; // 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); @@ -57,7 +51,7 @@ async function perfRender(lighting, overlays) { 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) { + if (GPU.queries) { await GPU.timingPromise; PERF_MARKS = true; drawWorld(); await GPU.timingPromise; @@ -66,7 +60,7 @@ async function perfRender(lighting, overlays) { 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: R.gpuOn ? 1 : SM.scale, actual_look: SET.look, renderer: R.gpuOn ? 'webgpu' : 'legacy', override_cells: GPU.overrideCount || 0, gpu_ms }; + scale: 1, actual_look: SET.look, renderer: 'webgpu', override_cells: GPU.overrideCount || 0, gpu_ms }; } finally { PERF_MARKS = false; } } function perfRun(seconds) { @@ -84,7 +78,7 @@ function perfRun(seconds) { requestAnimationFrame(frame); setTimeout(() => { window.__perfHalt(); PERF_FRAME = null; GAME.paused = true; - resolve({ samples, end: perfState(), scale: R.gpuOn ? 1 : SM.scale, actual_look: SET.look, renderer: R.gpuOn ? 'webgpu' : 'legacy', override_cells: GPU.overrideCount || 0, gpu_ms: R.gpuOn ? GPU.gpuMs : null }); + resolve({ samples, end: perfState(), scale: 1, actual_look: SET.look, renderer: 'webgpu', override_cells: GPU.overrideCount || 0, gpu_ms: GPU.gpuMs }); }, seconds * 1000); }); } diff --git a/tools/perf-scenes.js b/tools/perf-scenes.js index 8697de7..094ce47 100644 --- a/tools/perf-scenes.js +++ b/tools/perf-scenes.js @@ -61,6 +61,6 @@ function perfView(look, zoom) { SET.look = look; SET.zoom = zoom; SET.lighting = 'full'; resizeRender(); snapCamera(); for (let k = 0; k < 5; k++) { R.frame++; drawWorld(); } - if (look === 'smooth' && (!R.smoothOn || (!R.gpuOn && !SM.ok))) throw Error('Smooth look unavailable: ' + SM.why); - return { grains: R.bw * R.bh, buffer: [R.bw, R.bh], scale: R.gpuOn ? 1 : SM.scale }; + if (look === 'smooth' && !R.smoothOn) throw Error('Smooth look unavailable'); + return { grains: R.bw * R.bh, buffer: [R.bw, R.bh], scale: 1 }; } diff --git a/tools/perf.py b/tools/perf.py index 29a7bc3..a5a927b 100644 --- a/tools/perf.py +++ b/tools/perf.py @@ -162,10 +162,8 @@ def main(): 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('''() => { - if (!smoothAvailable()) throw Error('Real GPU unavailable: ' + SM.why); - const gl = SM.gl, dbg = gl.getExtension('WEBGL_debug_renderer_info'); - 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), + return { renderer: 'WebGPU ' + CAPS.adapterInfo.vendor + ' ' + CAPS.adapterInfo.architecture, + vendor: CAPS.adapterInfo.vendor, cores: navigator.hardwareConcurrency, crossOriginIsolated, webgpu: !!navigator.gpu, adapter: CAPS.adapterInfo, timestamp_query: !!GPU.queries, user_agent: navigator.userAgent, dpr: devicePixelRatio }; diff --git a/tools/shot.py b/tools/shot.py index 810783d..d2b9bce 100644 --- a/tools/shot.py +++ b/tools/shot.py @@ -13,7 +13,6 @@ code and the bot is moved there before the shot. SX, SY are the bot's starting p Options: --look pixel|smooth which look to draw (default: pixel; use smooth only to check graphics) - --renderer webgpu|legacy pixel renderer (default: webgpu) --seed N world seed (default 424242) --size WxH world size in grains (default 2048x1024) --setup FILE.js code to run once the world is ready (see tools/scenes/ for examples) @@ -52,7 +51,6 @@ def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('out') ap.add_argument('--look', choices=['pixel', 'smooth'], default='pixel') - ap.add_argument('--renderer', choices=['webgpu', 'legacy'], default='webgpu') ap.add_argument('--seed', type=int, default=424242) ap.add_argument('--size', default='2048x1024') ap.add_argument('--setup') @@ -78,7 +76,6 @@ def main(): pg.goto(url) pg.evaluate("SET.fog = false") pg.evaluate('CAPS_CHECK') - pg.evaluate('(r) => { 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 @@ -93,7 +90,7 @@ def main(): if code: pg.evaluate(f'() => {{ {code}\n}}') # Setup scenes can change surfY after the renderer cached the world's horizon. - pg.evaluate('SM.surf = -1') + pg.evaluate('SM.surf = -1; renderDirty.fill(1); GPU.world=null;') if a.ticks: pg.evaluate(f'for (let k = 0; k < {a.ticks}; k++) simTick();') pg.evaluate(f"""GAME.paused = true; $('hud').hidden = {'false' if a.hud else 'true'}; GAME.hidePreviews = {'false' if a.previews else 'true'}; @@ -105,17 +102,15 @@ 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.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(',')) kw['clip'] = {'x': x, 'y': y, 'width': w, 'height': h} pathlib.Path(a.out).parent.mkdir(parents=True, exist_ok=True) pg.screenshot(path=a.out, **kw) - note = pg.evaluate("SET.look === 'smooth' ? (R.smoothOn ? 'smooth look' : 'smooth look FAILED: ' + SM.why) : 'pixel look'") + note = pg.evaluate("SET.look === 'smooth' ? (R.smoothOn ? 'smooth look' : 'smooth look FAILED') : 'pixel look'") b.close() - print(f"saved {a.out} ({note}, {a.renderer})") + print(f'saved {a.out} ({note}, WebGPU)') for e in errors: print('ERROR', e) sys.exit(1 if errors else 0)