From 2d15c7ab8d3af45e3b86ddb70b073c99368ae819 Mon Sep 17 00:00:00 2001 From: Toad Date: Mon, 24 Aug 2026 15:17:53 +0200 Subject: [PATCH] refactor: move world concepts into engine --- .agents/history/jerklint-01/review.md | 137 ---- .agents/plans/gpu.md | 714 ++++++++++++++++++ .agents/plans/levels-and-editor.md | 15 +- .agents/rules/forge-sync.md | 29 - .agents/rules/openspec.md | 19 - .claude/skills | 1 - AGENTS.md | 143 ++-- CLAUDE.md | 10 - CONTEXT.md | 29 + app/main.ts | 205 ++--- app/render-worker.ts | 88 +-- app/renderer.ts | 228 ++++-- docs/adr/0001-engine-owns-world-concepts.md | 3 + engine/math/Mat4.ts | 2 +- engine/render/Chunk.ts | 29 + engine/render/ChunkBuilder.ts | 123 +++ engine/render/Rasterizer.ts | 6 +- engine/render/RenderConfig.ts | 6 +- engine/render/RenderProtocol.ts | 123 +++ engine/render/RenderScene.ts | 224 ++++++ engine/scene/Actor.ts | 68 +- engine/scene/MeshBuilder.ts | 80 ++ engine/scene/Prefab.ts | 38 + engine/world/CharacterController.ts | 89 +++ engine/world/Collider.ts | 116 +++ engine/world/CollisionWorld.ts | 62 ++ engine/world/Level.ts | 123 +++ engine/world/Terrain.ts | 121 +++ game/Terrain.ts | 106 --- game/actors/Boulder.ts | 37 +- game/actors/Bush.ts | 18 +- game/actors/Flower.ts | 46 +- game/actors/Mob.ts | 86 +-- game/actors/Tree.ts | 43 -- game/actors/mobs/Bee.ts | 37 +- game/actors/mobs/Frog.ts | 37 +- game/actors/mobs/Robin.ts | 37 +- game/actors/mobs/mobkit.ts | 59 +- game/actors/trees/Birch.ts | 66 +- game/actors/trees/Oak.ts | 55 +- game/actors/trees/Spruce.ts | 51 +- game/actors/trees/treekit.ts | 5 +- game/level.ts | 794 ++++++++++---------- game/player.ts | 181 +---- game/renderScene.ts | 133 ---- meat.code-workspace | 5 +- tests/actors.test.ts | 38 + tests/layering.test.ts | 42 +- tests/level.test.ts | 72 ++ tests/mobs.test.ts | 21 - tests/render-protocol.test.ts | 38 + tests/trees.test.ts | 18 - 52 files changed, 3298 insertions(+), 1558 deletions(-) delete mode 100644 .agents/history/jerklint-01/review.md create mode 100644 .agents/plans/gpu.md delete mode 100644 .agents/rules/forge-sync.md delete mode 100644 .agents/rules/openspec.md delete mode 120000 .claude/skills delete mode 100644 CLAUDE.md create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-engine-owns-world-concepts.md create mode 100644 engine/render/Chunk.ts create mode 100644 engine/render/ChunkBuilder.ts create mode 100644 engine/render/RenderProtocol.ts create mode 100644 engine/render/RenderScene.ts create mode 100644 engine/scene/MeshBuilder.ts create mode 100644 engine/scene/Prefab.ts create mode 100644 engine/world/CharacterController.ts create mode 100644 engine/world/Collider.ts create mode 100644 engine/world/CollisionWorld.ts create mode 100644 engine/world/Level.ts create mode 100644 engine/world/Terrain.ts delete mode 100644 game/Terrain.ts delete mode 100644 game/renderScene.ts create mode 100644 tests/actors.test.ts create mode 100644 tests/level.test.ts delete mode 100644 tests/mobs.test.ts create mode 100644 tests/render-protocol.test.ts delete mode 100644 tests/trees.test.ts diff --git a/.agents/history/jerklint-01/review.md b/.agents/history/jerklint-01/review.md deleted file mode 100644 index de6ccb8..0000000 --- a/.agents/history/jerklint-01/review.md +++ /dev/null @@ -1,137 +0,0 @@ -# Jerklint Review 01 - -Reviewed 2026-08-04. Scope: handwritten source under `engine/`, `app/`, -`server/`, `shared/`, and `scripts/`; root runtime, build, lint, editor, and -project configuration; `README.md`. Generated assets, build output, -dependencies, and lockfile were excluded. - -**Jerklint Findings** - -1. **High** `engine/render/Color.ts:37-43`, - `engine/render/Texture.ts:46-56`, `engine/render/Rasterizer.ts:199-203`, - `engine/render/RenderConfig.ts:104-114`, `scripts/gen-assets.ts:136-143`: - `Color.lerp` promises to interpolate a `Color` but omits alpha, so `Color.rgb` - silently supplies `255`. Every bilinear sample therefore becomes opaque. - The `clean` preset enables linear filtering, while the NPC asset has a - transparent background and alpha cutout happens after sampling. This is a - false generic abstraction with a direct behavior change hidden behind an - unrelated render knob. Smallest fix: interpolate alpha in `Color.lerp`; add - an explicitly named RGB-only blend only if fog or sky ever needs one. - -2. **High** `engine/render/RenderConfig.ts:16-58`, - `engine/render/RenderConfig.ts:64-115`, `app/main.ts:19-29`, - `engine/render/Framebuffer.ts:43-73`: live render configuration has no - authoritative validity or application boundary. Public presets are mutable - singleton objects, `useConfig` retains the supplied alias, dimensions are - snapshotted into framebuffer resources, and documented numeric ranges are - not enforced. A direct width change leaves resources stale; values such as - `colorDepth = 0` make quantization divide by zero. This will become immediate - pressure when planned live sliders arrive. Smallest fix: make presets - readonly templates, create a fresh validated config for each update, and - route updates through one app-side apply function that rebuilds resources - when dimensions change. - - -4. **Medium** `app/level.ts:18-27`, `app/level.ts:66-105`, - `app/player.ts:2`, `app/player.ts:19-31`, `app/player.ts:61-97`, - `app/main.ts:48-52`: collision policy has no single owner. `Player.update` - accepts the render-heavy `Level`, then reconstructs gameplay facts using a - hardcoded ground height and NPC radius; NPC visual size lives in `main.ts`. - `buildLevel` also constructs geometry, collision, NPC placement, and sky in - one body. NPC or floor changes therefore require cross-file agreement and - isolated player tests need unrelated render data. Smallest fix: let `Level` - own a compact NPC descriptor and a narrow collision world containing ground, - boxes, and circles; pass only that collision value to `Player.update`. - -5. **Medium** `app/main.ts:54-69`, `app/player.ts:23-55`: browser key codes are - the player simulation API. Physics owns WASD and Space bindings, receives a - mutable `Set`, cannot represent a press edge, and therefore treats a - held jump key as a fresh jump whenever landing. Lost `keyup` events also - leave state stuck because no blur path clears the set. Rebinding, gamepad - input, and deterministic tests all require physics edits. Smallest fix: map - browser events in `main.ts` to a semantic `PlayerInput` value with movement - axes and `jumpPressed`; clear raw input on blur. - -6. **Medium** `engine/scene/Camera.ts:27-33`, - `engine/math/Mat4.ts:23-34`, `engine/render/Rasterizer.ts:9-19`, - `engine/render/Rasterizer.ts:58-68`, `engine/render/Rasterizer.ts:77-115`: - projection limits and clipping have separate authorities. Camera projection - declares near/far values of `0.05` and `100`, but `Rasterizer.project` - discards clip-space `z` and clips only against unrelated `w >= 0.01`; far - clipping is absent. `Rasterizer.draw` looks matrix-generic while relying on - perspective-specific `w` semantics for clipping, depth, and fog. Smallest - fix: retain clip-space `z` and clip against canonical near/far planes, or - narrow and name the API so its perspective-matrix contract is explicit. - -7. **Medium** `engine/render/RenderConfig.ts:48-54`, - `engine/render/Texture.ts:36-38`, `engine/render/Rasterizer.ts:51-57`, - `engine/render/Sky.ts:34-36`, `engine/render/Sky.ts:93-98`, - `engine/render/Sky.ts:121-128`: closed option unions fail open. New texture - filters silently become nearest, new lighting modes silently become unlit, - and new cloud kinds silently become basic because each dispatcher uses a - binary predicate plus fallback. `basicCumulus` accepts the full `CloudLayer` - union, preventing TypeScript from exposing the missing branch. Smallest fix: - use exhaustive switches with a `never` check and narrow cloud helpers to - their concrete variant types. - -11. **Low** `engine/scene/Sprite.ts:9-23`, `app/main.ts:48-52`, - `app/main.ts:107-112`: `Sprite.texture` is assigned but never read; drawing - separately reaches back to `textures.npc`. Two sources can diverge, and the - type promises ownership the render path ignores. Smallest fix: draw with - `npc.texture`, or remove the field if material binding is intentionally - external. - -12. **Low** `engine/scene/Camera.ts:4-13`, `engine/math/Mat4.ts:37-43`, - `engine/render/Sky.ts:62-66`, `app/main.ts:73-79`: camera pitch validity is - documented by `Camera` but enforced by one caller through the unexplained - literal `1.4`. Both camera and sky basis construction rely on that ritual. - Any second camera producer can create a degenerate basis. Smallest fix: own - the pitch limit and look-delta/clamp operation in the `Camera` namespace. - -14. **Low** `engine/math/Vec3.ts:3-4`, `engine/math/Vec3.ts:34-37`: - `Vec3.normalize` violates its namespace-wide fresh-result guarantee only for - zero vectors by returning the mutable input alias. Callers can safely mutate - ordinary results but unexpectedly mutate source state at one edge. Smallest - fix: return a fresh zero vector. - -**Scorecard** - -- DRY: concern - collision, release, and resource-update policy have split owners. -- KISS: pass - core engine stays direct, data-oriented, and framework-free. -- YAGNI: concern - two unused public placeholders remain. -- SOC: concern - render config application and release artifacts mix concerns. -- Cohesion: concern - player physics consumes unrelated level rendering data. -- Coupling: fail - config/resources and projection/clipping rely on hidden cross-module rules. -- Dependency Direction: pass - browser code points inward and engine remains DOM-free. -- Law of Demeter: pass - shallow plain-data access; no traversal chains or service locators. -- Immutability: concern - mutable preset aliases and zero-vector aliasing weaken boundaries. -- Declarative Shape: concern - option unions and release phases are not exhaustively interpreted. -- Implicit Contracts: fail - alpha, dimensions, pitch, clipping, checks, and publishing depend on rituals. -- Abstraction Pressure: concern - important rules are under-centralized while stale exports remain. -- Naming/API Clarity: fail - `Color.lerp`, `check`, `publish`, and `Sprite.texture` overpromise. -- Locality: concern - NPC behavior and configuration changes require cross-file coordination. -- Testability: fail - zero tests, false-green test command, and broad player fixtures. -- File/API Shape: concern - type/namespace pattern is strong; ignored and stale exports weaken it. -- Predicate Accuracy: concern - binary fallback dispatch silently accepts future union variants. -- Construction Phase Separation: concern - level and release construction each hide multiple phases. - -**Strengths** - -- Engine/browser dependency boundary is clean and enforced by separate TypeScript libraries. -- Domain types generally own behavior through matching namespaces and matching filenames. -- Hot-loop mutation is local, explicit, and appropriate for a software rasterizer. -- Rasterizer, sky, texture loading, and procedural mesh generation remain cohesive despite numeric code. -- `main.ts` is still a reasonable composition root; splitting presentation or FPS code now would add ceremony. -- No ECS, service locator, class hierarchy, utility bag, dependency cycle, or speculative plugin system appeared. - -**Validation** - -- `bun run build`: passed. -- `bunx tsc --build --dry`: selected engine, app, and config only. -- `bun run lint`: exited with one `typescript(array-type)` warning at `scripts/gen-assets.ts:162`. -- `bun run test`: found zero tests and emitted Bun's tsconfig directory-mismatch internal error; `--pass-with-no-tests` kept the command green. - -**Verdict** - -Refactor soon. Keep the core architecture. Fix lying primitives and quality -commands first, then centralize configuration, input, and collision contracts. diff --git a/.agents/plans/gpu.md b/.agents/plans/gpu.md new file mode 100644 index 0000000..1cc2ec4 --- /dev/null +++ b/.agents/plans/gpu.md @@ -0,0 +1,714 @@ +# GPU renderer migration direction note + +Status: **options open; migration seam agreed.** This note records the current +problem, viable approaches, expected scaling, proposed architecture, migration +order, and decisions still needed. It is not approval to delete the software +renderer or to commit to WebGL2, WebGPU rasterization, or WebGPU compute. + +## Goal + +Move triangle transformation and pixel rasterization off the CPU so adding more +world geometry and moving actors does not collapse frame rate. + +The GPU path must preserve the configurable PS1 look rather than replace it with +a fixed visual preset. `RenderConfig` remains the source of live settings for +internal resolution, vertex snap, texture filtering, lighting, fog, color depth, +dither, and LOD distance. + +The current software renderer remains playable during migration. A GPU backend +is added beside it and selected at startup or by a debug option until parity and +performance are proven. + +## Decisions already made + +- Build a parallel backend instead of replacing the software renderer in place. +- Keep engine world/content boundaries unchanged. GPU work must not reintroduce + content kinds, registries, or renderer knowledge of frogs, trees, texture names, + or other game content. +- Keep `RenderScene` as the clone-safe, content-agnostic render projection of a + live `Level`. +- Keep main-thread frustum culling and scene-local render prototype indexes at + first. GPU culling is not required to obtain the expected performance gain. +- Optimize for scaling as content grows, not merely for improving the current + benchmark by a small constant factor. +- Preserve low-resolution rendering and the live PS1 controls. + +## Decisions not made + +- WebGPU versus WebGL2 as the first GPU backend. +- Fixed-function GPU rasterization versus a custom WebGPU compute rasterizer. +- Whether WebGL2 and WebGPU should both exist long-term. +- Whether the software renderer remains a permanent fallback after a GPU backend + becomes default. +- Whether GPU output must be pixel-identical to software output or only visually + equivalent under the same `RenderConfig`. +- Required browser, OS, and device support. +- Whether GPU timing statistics are required for the first usable backend. + +## Current baseline + +Current final browser benchmark at 384x216: + +| Backend | Work median | Work p95 | Frame p95 | +| --- | ---: | ---: | ---: | +| Software, single thread | 30.07 ms | 32.19 ms | 32.60 ms | +| Software, 3 workers | 11.16 ms | 13.35 ms | 16.88 ms | + +These numbers are machine- and scene-specific. They are useful as the baseline +for this repository, not as universal predictions. + +The software cost grows with transformed triangles, covered pixels, and texture +samples. Workers divide framebuffer rows, but every worker still loops over the +same visible draw groups and transforms the same triangles for its band. More +content therefore keeps increasing CPU work even when screen resolution stays +fixed. + +## Remaining implementation options + +### Option A: standard WebGPU rasterization + +Use WebGPU render pipelines, vertex/index buffers, textures, samplers, a depth +texture, actor instancing, and WGSL shaders. Recreate PS1 traits in shaders and a +post-process pass. + +Strengths: + +- Uses hardware raster units for the work they are designed to perform. +- Best route to large geometry and actor-count headroom. +- Explicit resource and command model fits immutable compiled `RenderScene` data. +- Storage buffers and compute remain available later without changing API. +- Device-loss handling can fall back to the software backend. + +Costs: + +- More setup and resource-lifecycle code than WebGL2. +- Uniform/storage alignment and bind-group layout require care. +- Texture upload row pitch must satisfy WebGPU alignment requirements. +- Browser/device support must be checked against actual release targets. +- GPU work timing needs optional timestamp queries or coarse CPU submission timing. + +Expected scaling: + +Current geometry should normally fit in low single-digit GPU milliseconds on a +reasonable desktop GPU. At this low internal resolution, 50k triangles is small +for fixed-function GPU rasterization. Hundreds of thousands of visible triangles +should remain practical if draw count, state changes, and overdraw stay controlled. +These are estimates, not acceptance results. + +### Option B: standard WebGL2 rasterization + +Use a WebGL2 context, VAOs, vertex/index buffers, textures, samplers, an offscreen +framebuffer, a depth attachment, GLSL ES 3 shaders, and instanced actor draws. + +Strengths: + +- Simpler first standard-raster backend. +- Broad and mature browser implementation history. +- All current PS1 effects can be implemented without compute shaders. +- Hardware triangle throughput should be enough for this engine by a wide margin. +- Easier shader/program debugging in many browser tools. + +Costs: + +- More implicit global state than WebGPU. +- Resource binding and synchronization are less explicit. +- No general compute path if exact software rasterization becomes necessary. +- Large dynamic instance data is less flexible than WebGPU storage buffers. +- A later WebGPU backend would duplicate substantial platform code. + +Expected scaling: + +For this renderer, standard WebGL2 and standard WebGPU should have similar basic +triangle throughput because both use hardware rasterization. JavaScript draw-call +overhead, material changes, and actor submission are more likely to distinguish +them than raw fill rate at 384x216. + +### Option C: WebGPU compute software rasterizer + +Port the custom rasterizer to WGSL compute rather than using render pipelines. +This is the path with the best chance of preserving exact custom edge, depth, and +pixel rules, but it does not automatically scale like hardware rasterization. + +A serious implementation needs multiple stages: + +1. Transform vertices and clip triangles. +2. Compute screen bounds and assign triangles to 8x8 or 16x16 pixel tiles. +3. Store per-tile triangle lists with overflow handling. +4. Rasterize each tile in a workgroup, using workgroup memory where useful. +5. Resolve depth, alpha cutout, texture sampling, lighting, and color. +6. Run color quantization/dither and copy to the canvas. + +Expected speed: + +A properly tiled compute renderer could plausibly reduce the current scene to +roughly 1-5 ms on a decent desktop GPU. A naive port can be slower than the current +worker renderer. Exact performance depends on tile occupancy, atomics, overdraw, +texture access, and hardware. + +Scaling limitations: + +- Vertex transformation and binning still grow with triangle count. +- Dense tiles grow with local triangle count and overdraw. +- Triangle-parallel writes contend on depth and color. +- Pixel-parallel loops over every triangle are catastrophically expensive. +- Correct alpha-cutout depth ordering complicates packed atomic updates. +- Reproducing deterministic software ordering can serialize work. + +Use this option when exact software-raster behavior is more important than maximum +content headroom. It is not the recommended first answer to the current scaling +problem, but it remains open. + +### Option D: CPU rasterizer with GPU presentation + +Keep software rendering and upload the completed CPU framebuffer into a GPU texture +for presentation. + +Strengths: + +- Small migration step. +- Preserves exact current pixels. +- Can establish canvas, texture-upload, scaling, and backend-selection plumbing. +- May simplify or replace Canvas2D presentation. + +Limitations: + +- Does not move triangle or pixel work off the CPU. +- Does not solve frame-rate collapse as content grows. +- Adds an upload every frame. + +This remains useful as an intermediate bridge or diagnostic backend, not as the +destination for the stated performance goal. + +### Option E: both WebGPU and WebGL2 + +Implement a common backend contract, then supply WebGPU, WebGL2, and software +implementations. + +Strengths: + +- Best runtime coverage while retaining modern WebGPU capabilities. +- Allows direct performance comparison on the same machine and scene. +- Software remains the reference renderer. + +Costs: + +- Three renderers, shader languages, resource systems, and failure paths. +- PS1 feature fixes must be maintained in multiple implementations. +- Highest test and debugging burden. + +Do not start both GPU backends simultaneously unless supported-browser requirements +make that necessary. The common seam should permit a second backend without making +it mandatory. + +## Current architecture that should survive + +No game-content rewrite is needed. + +- `engine/world/Level.ts` owns live actors, collision, terrain, and render projection. +- `engine/render/RenderScene.ts` owns static groups, chunks, billboards, prototypes, + sky configuration, culling, and scene-local instance data. +- `engine/render/Chunk.ts` owns LOD choice. +- `Mesh` already provides flat position/UV vertices and indexed triangles. +- `Material` already binds a concrete `Texture` and cull setting. +- `ChunkBuilder` already batches static geometry by material object. +- Actor definitions already expose material-bound render prototypes. +- Main-thread frustum culling already prevents invisible chunks and actors from + reaching the renderer. + +GPU compilation should consume those objects directly. Backend-local maps may +deduplicate resources by `Mesh`, `Texture`, `Material`, or `RenderPrototype` object +identity. Such maps are compiled-resource caches, not semantic content registries: +they have no content names, stable IDs, or dispatch behavior. + +## Proposed backend seam + +Current `app/renderer.ts` exposes software-specific `fb` and `parallel` fields. +Presentation also lives partly in `app/main.ts`. A GPU backend needs a backend-neutral +surface. + +Candidate frame input: + +```ts +export type RenderFrame = { + camera: Camera + viewProjection: Mat4 + visibleChunks: readonly number[] + instances: readonly RenderInstance[] + time: number +} +``` + +Candidate renderer contract: + +```ts +export type Renderer = { + readonly width: number + readonly height: number + readonly backend: "software" | "webgl2" | "webgpu" + reconfigure: (config: RenderConfig) => void + dispatch: (frame: RenderFrame) => void + done: () => boolean + present: () => void + workMs: () => number | null + dispose: () => void +} +``` + +This is a candidate, not a final API. Important properties: + +- Main loop no longer reads a software `Framebuffer` to discover dimensions. +- Backend owns presentation. Software calls `putImageData`; WebGL/WebGPU finish a + pass targeting the canvas. +- Software `done()` polls workers; standard GPU backends can initially report true + after command submission. +- `workMs()` may be unavailable until GPU timing support exists. +- Renderer owns teardown for workers, buffers, textures, and device/context loss. +- Scene replacement or level reload recompiles backend resources explicitly. + +## Module ownership options + +Core engine must remain DOM-free and usable by the server. + +Recommended initial split: + +- `engine/render/RenderScene.ts` keeps backend-neutral scene and frame contracts. +- `app/renderers/SoftwareRenderer.ts` wraps the current worker renderer. +- `app/renderers/WebGpuRenderer.ts` or `app/renderers/WebGlRenderer.ts` owns browser + API calls and canvas contexts. +- `app/main.ts` selects a backend and uses only the common renderer contract. + +If a GPU backend grows too large for `app/`, another viable option is a separate +platform project such as `renderers/webgpu/` that depends on `engine/` but is not +part of DOM-free engine core. Do not add DOM/WebGPU globals to `tsconfig.engine.json` +merely to make placement convenient. + +## Standard GPU resource compilation + +Compile resources once per `RenderScene` or level load. + +### Meshes + +- Convert `Mesh.verts` into one `Float32Array` with stride 5: x, y, z, u, v. +- Convert `Mesh.indices` into `Uint16Array` when safe or `Uint32Array` otherwise. +- Create one backend mesh resource per distinct `Mesh` object. +- Preserve separate chunk meshes; merging all chunks would defeat CPU culling. +- Release resources when replacing the scene. + +### Textures + +- Upload existing packed RGBA bytes through a `Uint8Array` view. +- Keep repeat wrapping. +- Create nearest and linear samplers without mipmaps. +- Handle WebGPU `bytesPerRow` alignment with padded staging rows when needed. +- Deduplicate by `Texture` object identity. + +### Materials + +- Bind texture resources by `Material.texture` object identity. +- Keep culling as finite renderer capability, not content identity. +- Standard GPU paths will probably need two world pipeline variants: culled and + double-sided. +- Avoid one shader program or pipeline per game material. + +### Prototypes and instances + +- Compile each `RenderPrototype` once. +- Group visible instances by prototype each frame. +- Upload transform data once per frame. +- Draw repeated actors with instancing rather than one command per actor. +- Keep prototype indexes local to the compiled scene. + +### Billboards + +- Replace per-frame CPU quad construction with one static unit quad. +- Supply position/size as instance data. +- Derive camera-right direction in the vertex shader so billboards stay upright. + +## Standard GPU frame structure + +### Pass 1: scene color and depth + +Render at `RenderConfig.internalWidth` x `RenderConfig.internalHeight` into an +offscreen RGBA color texture plus depth attachment. + +Suggested order: + +1. Draw full-screen sky with depth writes disabled. +2. Draw static groups. +3. Draw visible chunks using CPU-selected near/far groups. +4. Draw billboards with alpha cutout and no culling. +5. Draw actor prototype instances. + +### Pass 2: quantization and presentation + +Sample the offscreen color texture and apply color-depth quantization plus Bayer +dither in a full-screen pass. Write to the canvas texture. Keep CSS integer scaling +and `image-rendering` behavior where useful. + +This two-pass shape matches current behavior better than quantizing each material +fragment independently because software quantization runs after the complete frame, +including sky. + +## Mapping current raster features to shaders + +| Current feature | Standard GPU implementation | Parity risk | +| --- | --- | --- | +| Perspective-correct UV | Native interpolation | Low; this is already desired behavior | +| Internal low resolution | Offscreen render target at configured size | Low | +| Nearest/linear texture filter | Select sampler from `RenderConfig` | Low | +| No mipmaps | Allocate only base level and use non-mipmap sampler | Low | +| Vertex snap | Snap projected screen coordinates in vertex shader, then rebuild clip xy | Medium; edge rules differ | +| Flat lighting | Fragment derivatives of world/view position, or baked face normals | Medium | +| Distance fog | Fragment shader using view-space distance | Low | +| Alpha cutout | Fragment `discard` below alpha threshold | Low | +| Backface culling | Culled and double-sided pipeline variants | Low; front-face sign must be checked | +| Color depth | Full-screen post-process quantization | Low | +| Bayer dither | Full-screen integer pixel-coordinate lookup | Low | +| 1/w depth behavior | Native perspective depth or explicit fragment depth | Medium; exact values differ | +| Near clipping | Native clipping | Low visually, not pixel-identical | +| Procedural sky | Full-screen sky shader port | Medium; noise parity must be tested | +| Chunk frustum culling | Keep existing CPU `RenderScene.visibleChunks` | Low | +| Actor culling | Keep existing `Level.visibleInstances` | Low | +| Chunk LOD | Keep existing CPU `Chunk.isFar` selection | Low | + +Vertex snap sketch: + +```text +clip = projection * view * model * position +ndc = clip.xy / clip.w +pixel = (ndc * 0.5 + 0.5) * internalResolution +pixel = round(pixel / snap) * snap +clip.xy = ((pixel / internalResolution) * 2.0 - 1.0) * clip.w +``` + +Y orientation and half-pixel conventions differ between APIs and need screenshot +tests. Do not guess front-face or snap signs from the software renderer. + +## Flat-lighting options + +### Fragment derivatives + +Calculate a face normal from `cross(dpdx(position), dpdy(position))` in the fragment +shader. This preserves flat faceted lighting without changing mesh data. + +Pros: + +- Smallest geometry change. +- Naturally one normal per rasterized face. +- Available in WebGL2 fragment shaders and WGSL fragment shaders. + +Cons: + +- Exact shade may differ from the CPU cross-product calculation. +- Orientation and two-sided handling need validation. + +### Baked face normals + +Duplicate shared vertices per triangle and store one face normal per vertex. + +Pros: + +- Explicit and predictable. +- Can reproduce CPU directional-light math closely. + +Cons: + +- Increases vertex memory. +- Requires mesh compilation to expand indexed geometry. +- Changes cache behavior. + +Both options remain open. Derivatives are the recommended first implementation. + +## WebGPU-specific notes + +- Request adapter/device once and handle failed acquisition cleanly. +- Configure the canvas using the preferred canvas format. +- Use an offscreen `rgba8unorm` scene texture and `depth24plus` initially. +- Keep frame uniforms in a uniform buffer. +- Use dynamic uniform offsets, instance vertex attributes, or storage buffers for + model transforms. Do not repeatedly overwrite one model uniform before submit. +- Respect 256-byte dynamic-uniform alignment. +- Group actors by prototype and use `instance_index` for transform lookup. +- Keep bind-group layouts stable across materials. +- Separate frame data, texture views, and samplers so changing texture filtering + does not force rebuilding every texture resource. +- Listen for device loss and switch to software rather than leaving a dead canvas. +- Treat timestamp queries as optional capability. +- Recreate size-dependent color/depth textures after `RenderConfig` resolution + changes. + +## WebGL2-specific notes + +- Request `webgl2` with alpha and antialias settings chosen explicitly. +- Use one VAO per compiled mesh. +- Use an FBO with RGBA color texture and depth renderbuffer/texture. +- Use GLSL ES 3 vertex/fragment programs for world, billboard, sky, and post passes. +- Use `drawElementsInstanced` and per-instance matrix attributes for actors. +- Use `dFdx`/`dFdy` fragment derivatives for flat lighting if selected. +- Use `EXT_disjoint_timer_query_webgl2` only when available. +- Handle context loss/restoration by releasing and recompiling scene resources. +- Reset or centralize state changes; implicit stale state is a major WebGL failure + mode. + +## WebGPU compute-raster details + +Avoid these naive designs: + +- One compute invocation per pixel looping over every triangle. +- One invocation per triangle writing non-atomic color and depth. +- A global atomic lock per pixel around full shading. +- Unbounded fixed-size tile lists with silent overflow. +- Recreating CPU row-band splitting on the GPU. + +Open design questions for compute: + +- Tile size and maximum triangle references per tile. +- Multi-pass prefix sums versus fixed tile capacity. +- Depth encoding suitable for atomic comparison. +- How alpha-cutout fragments update depth and color consistently. +- Whether exact triangle order is required for equal-depth fragments. +- Whether perspective-correct UV and texture filtering use native sampling. +- How clipping-generated triangles enter tile lists. +- Whether post-process quantization remains a separate render pass. + +Do not implement compute first unless pixel parity is declared a hard requirement +after testing a standard raster prototype. + +## Draw-call and batching strategy + +Current chunks are already grouped by material, which is suitable for GPU upload. +Culling should remain more important than globally merging geometry. + +Initial strategy: + +- Draw each visible chunk group separately. +- Skip invisible chunks on CPU. +- Select near/far groups on CPU. +- Sort or bucket visible groups by cull pipeline and material only if profiling + shows draw-call overhead matters. +- Instance actors by prototype. +- Use one unit-quad mesh for billboards. + +Possible later optimizations: + +- Merge neighboring chunk groups only if visibility remains acceptably granular. +- Multi-draw or indirect draws where API support and complexity justify them. +- GPU frustum/occlusion culling only after CPU submission becomes measured cost. +- Texture atlases or arrays only after material binding becomes measured cost. + +Do not add batching abstractions before a profile identifies the bottleneck. + +## Performance expectations and growth testing + +Standard GPU rasterization is recommended for the content-scaling goal because +hardware raster units remove the CPU per-triangle/per-pixel loop. However, GPU +rendering can still lose frame rate through excessive draws, overdraw, huge texture +uploads, per-frame allocation, or synchronization. + +Benchmark growth levels, not only the current scene: + +| Scenario | Purpose | +| --- | --- | +| 1x current content | Parity and baseline | +| 2x static props | Early scaling slope | +| 5x static props | Dense-world target | +| 10x static props | Stress and culling behavior | +| 2x/5x moving actors | Instance upload and draw scaling | +| Worst forest camera | Fill, overdraw, and visible draw count | +| Empty clearing | Fixed per-frame overhead | + +Record: + +- CPU simulation time. +- CPU render submission time. +- GPU time when available. +- Median, p95, and max frame time. +- Visible triangles, draw count, and visible actor count. +- GPU memory and scene compilation time where practical. + +Candidate budget, not yet agreed: + +- Keep renderer p95 below roughly 10 ms at the chosen content target, leaving + room inside a 16.67 ms frame for simulation, input, browser, and presentation. +- Demonstrate a substantially flatter frame-time slope than software at 2x and 5x + content. + +## Migration sequence + +### Phase 1: isolate backend contract + +1. Introduce `RenderFrame` and backend-neutral renderer dimensions/lifecycle. +2. Wrap current software renderer without changing output. +3. Move Canvas2D presentation behind the software backend. +4. Keep `?bench=st|mt` working. +5. Add explicit backend query selection such as `?renderer=software|webgl2|webgpu`. + +Acceptance: + +- Software screenshots and benchmark remain unchanged. +- Preset switching, resize, worker fallback, and presentation still work. + +### Phase 2: GPU scene compiler + +1. Traverse `RenderScene` once. +2. Deduplicate meshes, textures, materials, and prototypes by object identity. +3. Upload immutable resources. +4. Record compile time and resource counts. +5. Add disposal and level-reload behavior. + +Acceptance: + +- Compiling a scene does not mutate engine scene data. +- No content names or registries enter backend code. +- Repeated object references produce one GPU resource. + +### Phase 3: minimum standard-raster world + +1. Render static groups and visible chunk groups. +2. Add depth testing and material culling. +3. Add texture sampling. +4. Keep CPU culling and LOD. +5. Use a simple clear color before sky parity exists. + +Acceptance: + +- Geometry, UVs, winding, depth, and near/far LOD are visibly correct. +- Current scene already shows major software-to-GPU work-time reduction. + +### Phase 4: actors and billboards + +1. Compile render prototypes. +2. Group visible actors by prototype. +3. Upload per-frame transforms. +4. Draw actors with instancing. +5. Draw billboards from a shared unit quad. +6. Add alpha cutout. + +Acceptance: + +- No actor content dispatch exists in backend. +- Worker scene-local prototype semantics remain backend-neutral. +- Dynamic actor count scaling is measured. + +### Phase 5: PS1 look parity + +1. Implement vertex snap. +2. Implement nearest/linear sampler selection. +3. Implement flat lighting. +4. Implement fog. +5. Implement color-depth quantization and Bayer dither in post-processing. +6. Validate `standard`, `soft`, `clean`, and `ps1` presets. + +Acceptance: + +- Every current `RenderConfig` knob works live. +- No PS1 trait is silently hardcoded into GPU shaders. + +### Phase 6: sky parity + +1. Port gradient and sun. +2. Port skybox sampling. +3. Port basic cumulus. +4. Port fancy cumulus. +5. Preserve reduced cloud sampling where still useful. + +Acceptance: + +- Sky animation and panorama orientation match visually. +- Cloud cost is profiled separately at each internal resolution. + +### Phase 7: reliability and selection + +1. Handle WebGPU device loss or WebGL context loss. +2. Fall back to software when GPU initialization fails. +3. Verify preset switching and resize during active rendering. +4. Add screenshot comparison and growth benchmarks. +5. Decide default backend only from support and measured results. + +## Validation plan + +### Visual scenes + +Capture stable views that expose different failure modes: + +- Room edge crossing near plane. +- Terrain horizon with fog. +- Dense trees and boulders at LOD transition. +- Flowers and sprite alpha cutout. +- Frog/bee/robin close and far. +- Skybox poles and seam. +- Basic and fancy clouds. +- Backface-culling winding checks. + +### Comparison policy + +If standard GPU rasterization is selected, require visual equivalence rather than +bit-identical frames unless exact parity is later declared. Native clipping, depth, +edge fill, derivatives, and floating-point behavior will differ. + +If compute rasterization is selected specifically for exactness, define which +parts must match bit-for-bit before implementation. "Exact" cannot remain vague. + +### Automated checks + +- GPU scene compiler imports no game modules. +- Renderer backends import no concrete game modules. +- GPU compilation creates no stable content IDs. +- Software tests continue passing. +- Shader compilation errors fail backend initialization clearly. +- Scene replacement releases old resources. +- Config switch and resize recreate only size/config-dependent resources. +- Device/context loss reaches a working fallback. + +## Backend choice guide + +Choose standard WebGPU first when: + +- Target browsers/devices support WebGPU adequately. +- Future compute or storage-buffer work matters. +- Explicit resource management is worth the larger initial implementation. +- Software fallback is acceptable where WebGPU is absent. + +Choose WebGL2 first when: + +- Broad browser/device coverage is the priority. +- Fastest path to hardware rasterization is the priority. +- Current feature set is enough and compute is not required. +- Maintaining software fallback covers correctness/reference needs. + +Choose WebGPU compute first only when: + +- Exact custom raster behavior is a hard product requirement. +- Standard GPU raster tests prove shader emulation insufficient. +- The project accepts a tile-binning/atomic rasterizer as a major subsystem. +- WebGPU-only support is acceptable. + +Choose both standard GPU APIs only when: + +- Browser support requirements cannot be met by one GPU API plus software fallback. +- The maintenance cost is explicitly accepted. + +## Current recommendation + +Start with the backend seam, then prototype **standard WebGPU rasterization** while +keeping software fully operational. Standard WebGL2 remains a valid first backend +if target-browser research favors it. Measure one textured static world pass before +porting every visual feature. + +Do not begin with compute unless exact edge/depth behavior is declared mandatory. +For the stated problem, standard hardware rasterization offers the strongest chance +of adding substantially more content without frame time growing like the current +CPU rasterizer. + +## Questions required before implementation + +1. Which browsers, OS versions, and device classes must run the GPU path? +2. Is software-renderer pixel identity required, or is visual equivalence enough? +3. What content-growth target must hold 60 fps: 2x, 5x, 10x, or a concrete level? +4. Is the software backend a permanent supported mode or only migration/reference? +5. Is GPU timing required in the HUD for the first version? +6. Should backend selection be automatic, query-driven, or user-configurable? +7. Is WebGPU compute still desired after a standard-raster visual prototype exists? + +Until these are answered, keep all options above open and avoid API choices that +make WebGL2, WebGPU rasterization, or WebGPU compute unnecessarily impossible. diff --git a/.agents/plans/levels-and-editor.md b/.agents/plans/levels-and-editor.md index b43fb0a..05acc47 100644 --- a/.agents/plans/levels-and-editor.md +++ b/.agents/plans/levels-and-editor.md @@ -1,9 +1,18 @@ # Levels & the in-game editor — direction note -Status: **direction agreed, not yet built.** Captures a brainstorm so the next +Status: **historical proposal, partially superseded.** Captures a brainstorm so the next session starts from the conclusion, not a cold read. Nothing here is committed code; it's the shape we want and *why*. +> **Architecture note (2026-08-20):** Naming and ownership sections below are +> superseded by `docs/adr/0001-engine-owns-world-concepts.md` and `CONTEXT.md`. +> Canonical terms are now engine `LevelDefinition` (live-level creation input), +> engine `Level` (live runtime), and clone-safe engine `RenderScene`; game owns +> concrete definitions and values. A future serializable editor document needs its +> own name rather than overloading these runtime terms. References below to game-owned world contracts, content +> `kind` fields/registries, `game/Terrain.ts`, or `game/renderScene.ts` describe the +> old code only. Editor goals remain valid but must use the canonical boundary. + ## The pivot The game is heading toward a **multiplayer twitch shooter**. Each match loads a @@ -20,7 +29,7 @@ The game is heading toward a **multiplayer twitch shooter**. Each match loads a - **Persistence and player carry-over are YAGNI** for now — but must not be walled off (see hedges). -## Naming: `Level` (data) vs `Scene` (runtime) +## Historical naming proposal: `Level` (data) vs `Scene` (runtime) Agreed vocabulary — reads as **"bake a `Level` into a `Scene`."** Fits the type + namespace convention. @@ -210,7 +219,7 @@ field = mutate the document + re-bake. - **No hardcoded buffer sizes** tied to today's world — mob/framebuffer sizing already re-runs on `setup`; keep it so odd-shaped match maps just work. -## Current-state facts the next session will need +## Historical current-state facts (before 2026-08-20) - `buildLevel(textures): Level` (`game/level.ts:175`) is the single entry, called once in `app/main.ts`. Today's `Level` (`game/level.ts:69-81`) is a **bake result** diff --git a/.agents/rules/forge-sync.md b/.agents/rules/forge-sync.md deleted file mode 100644 index 6270721..0000000 --- a/.agents/rules/forge-sync.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -description: Local vs sync mode rules for git and Forgejo — commit, push, and PR policy per workflow. ---- - -# Forge Sync Rules - -When a workflow uses explicit mode: - -- `Mode: sync` or `--sync` means sync mode. -- no sync marker means local mode. -- Do not ask whether git workflow should be agent-managed. - -Local mode: -- No push. -- No forge write tools. -- No PR create/update/comment. - -Sync mode: -- Commit completed workflow units. -- Push after each unit commit. -- Create PR after first push if missing. -- Reuse existing PR on later pushes. -- Comment/update PR only when workflow asks for it. - -Forbidden always: -- No force push. -- No branch deletion. -- No hard reset. -- No amending unless explicitly requested. diff --git a/.agents/rules/openspec.md b/.agents/rules/openspec.md deleted file mode 100644 index b8b8bf1..0000000 --- a/.agents/rules/openspec.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Rules for OpenSpec propose and archive workflows. ---- - -# OpenSpec Instructions - -## OpenSpec Propose Workflow - -When proposing changes via `/opsx-propose`: - -- If the change introduces or modifies user-facing behavior (API changes, conventions), include a task section for updating `README.md` in the proposal's impact assessment. - -## OpenSpec Archive Workflow - -When archiving tasks via `/opsx-archive`: - -1. Automatically sync specs, do not ask the user. -2. If mode is explicit, use `forge-sync` for archive commit, push, PR, and Forgejo behavior. -3. If no mode is explicit, default to local mode. diff --git a/.claude/skills b/.claude/skills deleted file mode 120000 index 2b7a412..0000000 --- a/.claude/skills +++ /dev/null @@ -1 +0,0 @@ -../.agents/skills \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index ec19f68..fb5325f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,14 +26,19 @@ rules live in `.agents/rules/*.md`. frame; on-screen solids also **backface-cull**. This is what keeps a dense world (thousands of trees/rocks) affordable — off-screen content costs ~nothing. - **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**. -- **Three layers, one-way deps: `engine` ← `game` ← `app`.** `engine/` is the - reusable, content-agnostic **mechanism** (rasterizer, framebuffer, culling, the - `Actor`/`Material` interfaces) — no DOM, no game content, could run server-side. - `game/` is **this game's content** (the creatures/props, terrain, level, scene - assembly + render orchestration) built on the engine interfaces. `app/` is - **browser glue** (canvas, input, image decode, the worker render driver + loop). +- **Three layers, one-way deps: `engine` ← `game` ← `app`.** `engine/` owns + reusable, content-agnostic **concepts and mechanisms**: levels, terrain, actors, + prefabs, collision, render scenes, chunking/LOD, rasterization, and worker render + transport. `game/` owns **this game's concrete content**: actual level values, + placements, materials, and frog/bee/robin or oak/spruce/birch definitions. + `app/` is browser glue (canvas, input mapping, image decode, workers, frame loop). Enforced: `engine/` imports nothing from `game`/`app`, `game/` nothing from `app` (`tests/layering.test.ts`). Both `engine` and `game` are DOM-free (tsconfig). +- **No closed game-content kinds or registries.** Instances reference concrete + `ActorDefinition`/`Prefab` objects directly. Do not add `MobKind`, `TreeKind`, + `*_KINDS`, content dispatch switches, or stable content-order protocols. Numeric + prototype indexes are scene-local engine transport details only. Closed unions + remain valid for finite engine capabilities such as collider or cloud shape. ## Stack & tooling @@ -57,7 +62,7 @@ rules live in `.agents/rules/*.md`. - `bunx tsc --build tsconfig.app.json` — **typecheck the engine+game+app graph. Use this**, not `bun run check` (see Caveats). - `bunx oxlint engine game app` — lint. -- `bun test` — tests (registry id-order + engine↛game layering guards). +- `bun test` — tests (world compilation, render protocol, boundary guards). - `bun run serve` — Bun server (`server/server.ts`, a stub for now). ## Layout @@ -71,73 +76,51 @@ rules live in `.agents/rules/*.md`. (optional backface cull per draw), `Frustum` (6 planes from the viewProj + AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no mipmaps), `Material` (texture + cull flag; a `DrawGroup` pairs a mesh with one, - so the renderer draws by list, not by named texture), `Sky` (gradient + sun + - procedural clouds; renders at 1/`step` res). + so the renderer draws by list, not by named texture), `Chunk`/`ChunkBuilder` + (spatial batches + two-level LOD), `RenderScene` (clone-safe scene projection, + culling + band rendering), `RenderProtocol` (shared worker frame layout), and + `Sky` (full-resolution gradient + sun, procedural clouds sampled at 1/`step`). - `scene/` — `Camera` (fps yaw/pitch; far plane reaches the outdoor peaks), `Mesh` (indexed tris; verts stored flat: `STRIDE` floats x,y,z,u,v per vertex, no per-vertex objects — cache-friendly + alloc-free to draw), `Sprite` - (Y-axis billboard), `Actor` (the generic `Entity` interface — - build + update + bounds — that a content kind implements; the engine dispatches - through it, never a `kind` switch). -- `game/` — this game's content + world assembly, on the engine interfaces + (Y-axis billboard), `MeshBuilder` (generic quad/slab/box construction), `Actor` + (open behavior + render/collider contract), and `Prefab` (open static-content + contract + type-erased placed value). + - `world/` — `Terrain` (height contract, built-in rolling generator, generic + patch meshing), `Collider`/`CollisionWorld`, `CharacterController`, and `Level` + (live actor/collision state + clone-safe `RenderScene` projection). +- `game/` — this game's definitions and level data, on the engine interfaces (headless: no DOM, imports nothing from `app`). - `actors/` — the placeable things. `Mob` (a **roaming** creature — `frog` hops the ground, `bee` hovers/darts, `robin` mostly hops but now and then takes a - short powered flight — the only moving geometry; each kind an `Entity` in - `mobs/.ts` + shared `mobs/mobkit.ts`, assembled by the thin `Mob` registry. - Its local-space mesh is built once per kind; `Mob.update` steps the wander AI - (leashed to a home anchor, deterministic per evolving `seed`) each frame and the - live `position`/`heading`/`scale` become a per-frame model matrix at draw. - `MOB_KINDS` is the SAB id order). `Tree` (oak/spruce/birch, each a `TreeSpecies` - in `trees/.ts` + `trees/treekit.ts` — see the Trees section), `Boulder` + short powered flight — the only moving geometry; each module exports an + `ActorDefinition` factory and instances hold the resulting object directly. + `Tree` is shared placement state; Oak/Spruce/Birch modules export material-bound + `Prefab` factories with no species registry. `Boulder` (squashed jittered part-buried sphere), `Bush` (leaf-blob cluster, shares the leaf mesh), `Flower` (stem + colored bloom, 2x2 atlas, double-sided). Baked props append into shared per-material meshes; mobs draw live. - - `Terrain.ts` — procedural heightfield around the room (flat clearing, rolling - hills, tall edge peaks). `Terrain.patch` builds one ground patch over a rectangle - (per chunk, welds crack-free, hole for the room); `Terrain.height` is the shared - ground sampler for the player + mobs. - - `level.ts` — builds the playground: a flat stone-floored room (three thick - walls via `slab`, north side open) always drawn, in the center of a big grassy - `Terrain` world (~20x across). Props are placed first (`placeTrees` / - `placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders; - `TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`) and the roaming mobs - scattered (`placeMobs`; `FROG_/BEE_/ROBIN_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs - move, so no baked colliders), then `buildChunks` bakes terrain + props into a - `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = a tight AABB + two - `DrawGroup[]` lists `near`/`far`; the baker accumulates one mesh per **material - key** (`MAT_ORDER`) and routes each prop by its declared material, so it names no - texture) that `main` frustum-culls. Trees + boulders bake **twice** — full into - `near`, a low-poly impostor into `far` — so a far chunk swaps to the cheap set - with no per-frame work (`chunkFar` / `RenderConfig.lodDistance`). - `buildLevel(textures)` binds the ground/prop `Material`s once + shares them. - Also: `Aabb` colliders, NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`, - sky/cloud config, `FLOOR_LIFT` (a z-bias lifting the stone floor over the terrain - skirt). Room surfaces are single flat quads (texturing is perspective-correct). - - `renderScene.ts` — `renderBand(fb, scene, …, mobDraws, …, y0, y1)`: the single - source of render truth (sky + room + culled chunk draw-groups + sprite + roaming - mobs + quantize for a row band). Used full-height by the inline path, per-band by - each worker. `Scene` bundles the static meshes/textures (incl. the canonical mob - meshes) so it clones to a worker whole; each mob draws double-sided through its - own `viewProj × Mat4.compose(...)` model matrix, and `visibleChunks`/`visibleMobs` - frustum-cull per frame. `textures.ts` holds the `Textures` palette type. - - `player.ts` — feet-cylinder player: gravity/jump + Shift-run (`RUN_MULTIPLIER`) - + circle-vs-AABB/-circle collision, substepped so fast running can't tunnel - walls; ground height from `Terrain.height` (plus standable AABBs). + - `level.ts` — concrete playground values and placement policy: room dimensions, + rolling-terrain parameters, counts/seeds/reach, material bindings, sky, and + spawn definition lists. It places direct `Prefab` objects, submits the resulting + values to engine `ChunkBuilder`, then creates engine `Level`; no game renderer or material-key + registry exists. Trees + boulders bake full and far-impostor geometry; bushes + and flowers provide near geometry only. + - `player.ts` — concrete player tuning only; movement and collision live in + engine `CharacterController`. - `app/` — browser glue only (top layer; depends on `game` + `engine`). - `main.ts` — game loop: input, sim, preset switching, per-frame culling, then the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS upscale) + a multi-line frame HUD (`work + present` critical-path ms, vsync, visible - chunks / LOD-aware tris). Owns the **mob sim**: steps `Mob.update` for every mob, - rebuilds near-player mob colliders into `level.colliders`, culls mobs - (`visibleMobs`) so only visible transforms dispatch. + chunks / LOD-aware tris). Calls engine `Level` simulation/collider extraction, + maps browser keys into `CharacterInput`, and dispatches visible render instances. - `renderer.ts` — the render driver. When the page is cross-origin-isolated it runs a pool of `render-worker.ts` threads (`MAX_WORKERS`) over a `SharedArrayBuffer` framebuffer, each owning a disjoint row band, synced by a lock-free `Atomics` barrier; otherwise inline. `dispatch`/`done` are non-blocking so the caller paces on rAF. Per-frame inputs ride shared arrays: camera/matrix/visible-chunk list + - visible **mob transforms** (`mobState`, count in `MOBVIS`). `?bench=st|mt` A/Bs - the paths. + visible engine instance prototype indexes + transforms. `?bench=st|mt` A/Bs + the paths. Renderer and worker import no game modules. - `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes are already the `Color` layout); returns the `game` `Textures` palette. - `index.html` — Vite entry at repo root; holds the `#screen` canvas and the @@ -155,9 +138,10 @@ rules live in `.agents/rules/*.md`. ## Frame pipeline (`app/main.ts` `tick`) -`Mob.update` (all mobs) + rebuild near-player mob colliders → `Player.update` → -build `Camera` → `Camera.viewProjection` → `visibleChunks` + `visibleMobs` -(frustum-cull, once on the main thread) → `renderer.dispatch` (non-blocking) → +`Level.update` (all actors) + `Level.refreshActorColliders` → +`CharacterController.update` → build `Camera` → `Camera.viewProjection` → +`RenderScene.visibleChunks` + `Level.visibleInstances` (frustum-cull, once on the +main thread) → `renderer.dispatch` (non-blocking) → next rAF: `renderer.done()` ? `present` : skip this vsync. Frame N is presented while N+1 is dispatched; the pump never blocks or async-awaits, so it can't desync from rAF. @@ -171,16 +155,15 @@ an integer multiple (crisp letterbox, centered) once per resize/config, and `linear`). This replaced a per-frame main-thread `drawImage` that scaled to the whole window (cost grew with window size); present is now ~0.2ms. -`renderBand` runs `renderScene.renderBand` for rows [y0,y1): `Sky.render` at -1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Rasterizer.draw` +`renderBand` runs `RenderScene.renderBand` for rows [y0,y1): `Sky.render` with +clouds sampled at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Rasterizer.draw` floor/walls/crate (room, always) → for each visible `Chunk`, loop its draw-groups — -`near` or `far` chosen by the pure `chunkFar` test (dist² from camera to the chunk +`near` or `far` chosen by the pure `Chunk.isFar` test (dist² from camera to the chunk AABB vs `lodDistance²`): `near` is grass + full trees/rocks + flowers, `far` is grass + the cheap impostors (foliage/flowers dropped). Each group draws with its own `Material` (cull per-material, so solids backface-cull and flowers stay double-sided) -→ `Sprite.billboard(npc)` → -the roaming mobs (each: shared local mesh × its `Mat4.compose` model matrix, -double-sided) → `Framebuffer.quantize`. `chunkFar` is pure (camera + baked bounds + config +→ each billboard → each visible actor prototype instance (shared local draw groups × +its `Mat4.compose` model matrix) → `Framebuffer.quantize`. `Chunk.isFar` is pure (camera + baked bounds + config only), so every worker band picks the same LOD for a chunk → no horizontal seam. Multi-threaded: N workers each run `renderBand` over their band of the shared framebuffer in parallel; single-threaded: one call over @@ -225,9 +208,9 @@ off-screen or fogged each frame, so several things keep it cheap: chunks that fall outside the view. Behind you + off to the sides = free. - **Backface culling** (`draw(..., true)`) — ~halves fill on solid geometry (terrain, foliage, rock). See the Rasterizer note re winding. -- **Half-res sky** (`SKY_STEP` in `main`, default 2) — the cloud fbm runs per +- **Half-res clouds** (`SKY_STEP` in `app/renderer.ts`, default 2) — cloud fbm runs per pixel and dominated the frame; sampling once per 2×2 block quarters it. -- **Distance LOD** (`RenderConfig.lodDistance`, `chunkFar` in `renderScene`) — +- **Distance LOD** (`RenderConfig.lodDistance`, `Chunk.isFar`) — past `lodDistance` a chunk's trees + boulders swap to pre-baked low-poly impostors and its bushes/flowers drop; both meshes are baked once at load, and the near/far pick is a pure function of camera + chunk bounds, so it costs @@ -282,11 +265,10 @@ branching in the cloud shader. Cost scales with sky resolution — fine at ## Trees (`game/actors/Tree.ts` + `game/actors/trees/`) -Procedural low-poly geometry, faceted flat-shaded like everything else. Each species -is a `TreeSpecies` definition in its own `trees/.ts` module (geometry + -which chunk materials its trunk/foliage bake into); `Tree.ts` just assembles them -into a registry (`Tree.species(kind)`, `TREE_KINDS`) and shared primitives live in -`trees/treekit.ts`. Three `kind`s carry the species read purely by silhouette: +Procedural low-poly geometry, faceted flat-shaded like everything else. `Tree.ts` +contains only shared placement state. Oak, Spruce, and Birch each expose a concrete +`Prefab` factory from their own module; level data references those definition +objects directly, and shared geometry primitives live in `trees/treekit.ts`. - **`oak`** — short tapered trunk, a couple of branches, a broad cluster of lumpy canopy `blob`s (wider than tall, bushy). - **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing @@ -298,14 +280,11 @@ into a registry (`Tree.species(kind)`, `TREE_KINDS`) and shared primitives live `growth` (0..1) runs **sapling → full grown**: it scales height/girth and adds canopy blobs (oak/birch) / tiers (spruce); `seed` gives each tree its own wobble. -A species declares its `trunk`/`foliage` **material keys** (e.g. birch → white -`birch` trunk, oak `leaf` foliage); the chunk baker (`level.ts`) accumulates one -mesh per material key and routes each tree via `Tree.species(kind)` — so a forest -still batches into a few draw calls and the baker names no texture. `game/level.ts` -`placeTrees` seeds the forest and rolls the species. **Add a species** = add a -`trees/.ts` module (its geometry + material keys) + one entry in the `Tree` -registry; only a genuinely new material also needs a `Material` in `buildLevel` + -its key in `MAT_ORDER`. +Each factory receives concrete trunk/foliage `Material` objects and closes over +them. `ChunkBuilder` accumulates meshes by material object, so a forest still +batches into a few draw groups without string keys or a registry. `game/level.ts` +`placeTrees` selects from a weighted list of prefab objects. **Add a species** = +add one concrete prefab module and include its object in level data. **Boulders** (`game/actors/Boulder.ts`) work the same way: `Boulder.build` appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it @@ -317,7 +296,7 @@ colliders; add a new prop type by cloning the pattern (generator + scatter). ## Controls -WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `game/player.ts`) · mouse +WASD move · **Shift** run (speed from `Player.config` in `game/player.ts`) · mouse look (click canvas to pointer-lock) · **Space** jump · **1/2/3** switch look presets. FPS shown bottom-right. The room's north wall is open — walk out onto the terrain and toward the peaks. @@ -349,7 +328,7 @@ job tmp dir, not the repo. ## Roadmap / not yet built In-browser RenderConfig slider panel; mipmaps; `painter` depth mode; gouraud -lighting; more cloud types; more props / a weapon; more mob kinds + smarter mob +lighting; more cloud types; more props / a weapon; more mob definitions + smarter mob behavior (they wander + block/stand-on today, but don't yet react to the player). `shared/` is nearly empty. The FPS meter is static HTML + `textContent` writes only — no DOM-built UI yet (deliberate). diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index bd3438f..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,10 +0,0 @@ -@AGENTS.md -@.agents/rules/big-red-dog.md -@.agents/rules/caveman.md -@.agents/rules/commits.md -@.agents/rules/forge-sync.md -@.agents/rules/openspec.md -@.agents/rules/quality.md - -Don't nudge the user towards branching or commiting. User is perfectly capable of handling this. - diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..6091c46 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,29 @@ +# Meat Engine + +Meat separates reusable first-person game capabilities from one game's concrete world and creatures. + +## Language + +**Level Definition**: +A complete engine-shaped input for creating one live Level. Game code produces it from concrete values and definitions; it is not the worker rendering payload. +_Avoid_: Scene assembly + +**Level**: +A live playable world created from a Level Definition, including terrain, actors, collision, and rendering state. +_Avoid_: Map, scene + +**Terrain**: +A bounded ground surface that can report height at any horizontal world position. +_Avoid_: Ground mesh + +**Actor Definition**: +A concrete reusable definition of one moving thing's behavior, representation, transform, and physical presence. Actor instances reference definitions directly. +_Avoid_: Mob kind, actor kind, type registry + +**Prefab**: +A concrete reusable definition for static level content, including its geometry and optional physical presence. Placed prefabs reference definitions directly. +_Avoid_: Prop kind, tree kind, species registry + +**Render Scene**: +The behavior-free rendering projection of a Level. +_Avoid_: Level diff --git a/app/main.ts b/app/main.ts index 2e320e6..9a360e7 100644 --- a/app/main.ts +++ b/app/main.ts @@ -1,20 +1,18 @@ import { RenderConfig } from "../engine/render/RenderConfig" +import { RenderScene, type RenderInstance } from "../engine/render/RenderScene" import { Camera } from "../engine/scene/Camera" -import type { Mesh } from "../engine/scene/Mesh" -import { Mob, MOB_KINDS, type MobKind } from "../game/actors/Mob" -import type { Vec3 } from "../engine/math/Vec3" +import { + CharacterController, + type CharacterInput, +} from "../engine/world/CharacterController" +import { Level } from "../engine/world/Level" import { loadTextures } from "./assets" -import { buildLevel, type Level } from "../game/level" -import { EYE_HEIGHT, Player } from "../game/player" +import { buildLevel } from "../game/level" +import { Player, type Player as PlayerState } from "../game/player" import { createRenderer } from "./renderer" -import { chunkFar, visibleChunks, visibleMobs, type Scene } from "../game/renderScene" const FOV_DEGREES = 75 const FOV = (FOV_DEGREES * Math.PI) / 180 -/** How close (world units) a mob must be to the player to get a live collider. - * Mobs farther than this can't be touched this frame, so skip them -- keeps the - * per-frame collider list (and the player's collision loop) short. */ -const MOB_COLLIDE_RANGE = 3 const screen = document.querySelector("#screen")! const ctx = screen.getContext("2d")! @@ -28,7 +26,11 @@ function benchCamera(tv: number): Camera { const drift = tv * 0.12 const radius = 65 + 30 * Math.sin(tv * 0.25) return { - position: { x: Math.cos(drift) * radius, y: 3, z: Math.sin(drift) * radius }, + position: { + x: Math.cos(drift) * radius, + y: 3, + z: Math.sin(drift) * radius, + }, yaw: tv * 0.7, pitch: 0.05 * Math.sin(tv * 0.5), fov: FOV, @@ -39,41 +41,34 @@ function round2(n: number): number { return Math.round(n * 100) / 100 } -function benchStats(a: number[]): { median: number; p95: number; max: number; mean: number } { +function benchStats(a: number[]): { + median: number + p95: number + max: number + mean: number +} { const s = a.toSorted((x, y) => x - y) - const q = (p: number): number => s[Math.min(s.length - 1, Math.floor(p * s.length))] - return { median: round2(q(0.5)), p95: round2(q(0.95)), max: round2(s[s.length - 1]), mean: round2(a.reduce((x, y) => x + y, 0) / a.length) } + const q = (p: number): number => + s[Math.min(s.length - 1, Math.floor(p * s.length))] + return { + median: round2(q(0.5)), + p95: round2(q(0.95)), + max: round2(s[s.length - 1]), + mean: round2(a.reduce((x, y) => x + y, 0) / a.length), + } } async function main(): Promise { const textures = await loadTextures() const level = buildLevel(textures) - // Build each kind's canonical mesh once, shared by every instance (the sim - // supplies each mob's per-frame transform). Registry-driven -- a new kind needs - // no change here. - const mobMesh = {} as Record - for (const kind of MOB_KINDS) { - const m: Mesh = { verts: [], indices: [] } - Mob.build(kind, m) - mobMesh[kind] = m - } - const scene: Scene = { - chunks: level.chunks, - floor: level.floor, - walls: level.walls, - crate: level.crate, - npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } }, - mobMesh, - mobCount: level.mobs.length, - sky: level.sky, - textures, - } // `?bench=st` / `?bench=mt` runs a scripted flythrough and reports timings. const benchMode = new URLSearchParams(globalThis.location.search).get("bench") - const forceWorkers = benchMode === "mt" ? true : benchMode === "st" ? false : undefined + const forceWorkers = + benchMode === "mt" ? true : benchMode === "st" ? false : undefined let config: RenderConfig = RenderConfig.standard - const renderer = createRenderer(scene, config, forceWorkers) + const renderer = createRenderer(level.render, config, forceWorkers) + let inFlight = false let image = new ImageData(renderer.fb.width, renderer.fb.height) let colorBytes = new Uint8ClampedArray(renderer.fb.color.buffer) @@ -93,20 +88,30 @@ async function main(): Promise { // letterboxed upscale, done by the GPU). Recomputed only on resize/config. function layout(): void { const fb = renderer.fb - const scale = Math.max(1, Math.floor(Math.min(globalThis.innerWidth / fb.width, globalThis.innerHeight / fb.height))) + const scale = Math.max( + 1, + Math.floor( + Math.min( + globalThis.innerWidth / fb.width, + globalThis.innerHeight / fb.height, + ), + ), + ) const w = fb.width * scale const h = fb.height * scale screen.style.width = `${w}px` screen.style.height = `${h}px` screen.style.left = `${(globalThis.innerWidth - w) >> 1}px` screen.style.top = `${(globalThis.innerHeight - h) >> 1}px` - screen.style.imageRendering = config.upscaleFilter === "linear" ? "auto" : "pixelated" + screen.style.imageRendering = + config.upscaleFilter === "linear" ? "auto" : "pixelated" } retarget() function useConfig(next: RenderConfig): void { config = next renderer.reconfigure(next) + inFlight = false retarget() } @@ -122,9 +127,7 @@ async function main(): Promise { return } - const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true } - // Colliders past this index are the dynamic mob ones, rebuilt every frame. - const staticColliderCount = level.colliders.length + const player: PlayerState = Player.create() const keys = new Set() globalThis.addEventListener("keydown", (e) => { keys.add(e.code) @@ -154,26 +157,30 @@ async function main(): Promise { return } player.yaw += e.movementX * 0.0025 - player.pitch = Math.max(-1.4, Math.min(1.4, player.pitch - e.movementY * 0.0025)) + player.pitch = Math.max( + -1.4, + Math.min(1.4, player.pitch - e.movementY * 0.0025), + ) }) // Triangles drawn this frame (room + each visible chunk, LOD-aware) for the HUD. - function frameTris(visible: number[], cam: Camera): number { - let t = level.floor.indices.length + level.walls.indices.length + level.crate.indices.length - for (const i of visible) { - const c = level.chunks[i] - const groups = chunkFar(c, cam.position, config.lodDistance) ? c.far : c.near - for (const g of groups) { - t += g.mesh.indices.length - } - } - return (t / 3) | 0 + function frameTris( + visible: number[], + instances: RenderInstance[], + camera: Camera, + ): number { + return RenderScene.triangleCount( + level.render, + visible, + instances, + camera.position, + config.lodDistance, + ) } // Poll-based pump: present the finished frame, dispatch the next; if workers // aren't done we skip this vsync (no async/rAF desync). The HUD reports the // critical-path budget (work + present) so the real bottleneck is visible. - let inFlight = false let last = performance.now() let fpsLast = last let fpsFrames = 0 @@ -182,7 +189,13 @@ async function main(): Promise { let vsyncMax = 0 let lastPresent = performance.now() let lastVisible: number[] = [] - let lastCamera: Camera = { position: { x: 0, y: 0, z: 0 }, yaw: 0, pitch: 0, fov: FOV } + let lastInstances: RenderInstance[] = [] + let lastCamera: Camera = { + position: { x: 0, y: 0, z: 0 }, + yaw: 0, + pitch: 0, + fov: FOV, + } function show(): void { const p0 = performance.now() @@ -213,30 +226,42 @@ async function main(): Promise { fpsEl.textContent = `${fps} fps${tag}\n` + `work ${round2(workMax)} + present ${round2(presentMax)} = ${round2(workMax + presentMax)}ms\n` + - `vsync ${round2(vsyncMax)}ms · ${lastVisible.length} ch · ${frameTris(lastVisible, lastCamera)} tris` + `vsync ${round2(vsyncMax)}ms · ${lastVisible.length} ch · ${frameTris(lastVisible, lastInstances, lastCamera)} tris` fpsLast = now fpsFrames = 0 workMax = 0 presentMax = 0 vsyncMax = 0 } - for (const m of level.mobs) { - Mob.update(m, dt, level.terrain) - } - rebuildMobColliders(level, player.position, staticColliderCount) - Player.update(player, keys, dt, level) + Level.update(level, dt) + Level.refreshActorColliders(level, player.position, Player.actorCollisionRange) + CharacterController.update( + player, + readPlayerInput(keys), + dt, + level.collision, + Player.config, + ) const camera: Camera = { - position: { x: player.position.x, y: player.position.y + EYE_HEIGHT, z: player.position.z }, + position: { + x: player.position.x, + y: player.position.y + Player.config.eyeHeight, + z: player.position.z, + }, yaw: player.yaw, pitch: player.pitch, fov: FOV, } - const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height) - const visible = visibleChunks(level.chunks, viewProj) - const mobDraws = visibleMobs(level.mobs, viewProj) + const viewProj = Camera.viewProjection( + camera, + renderer.fb.width / renderer.fb.height, + ) + const visible = RenderScene.visibleChunks(level.render, viewProj) + const instances = Level.visibleInstances(level, viewProj) lastVisible = visible + lastInstances = instances lastCamera = camera - renderer.dispatch(camera, viewProj, visible, now / 1000, mobDraws) + renderer.dispatch(camera, viewProj, visible, now / 1000, instances) inFlight = true if (renderer.done()) { show() @@ -250,7 +275,7 @@ async function main(): Promise { * interval, then reports the distributions (exposed on `window.__BENCH__`). */ function runBench( renderer: ReturnType, - level: Level, + level: ReturnType, present: () => void, mode: string, ): void { @@ -282,7 +307,9 @@ function runBench( mode, parallel: renderer.parallel, cores: (globalThis.navigator as Navigator).hardwareConcurrency, - coi: (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true, + coi: + (globalThis as { crossOriginIsolated?: boolean }) + .crossOriginIsolated === true, res: `${renderer.fb.width}x${renderer.fb.height}`, workMs: benchStats(work), frameMs: benchStats(frame), @@ -306,14 +333,15 @@ function runBench( return } } - for (const m of level.mobs) { - Mob.update(m, 1 / 60, level.terrain) - } + Level.update(level, 1 / 60) const camera = benchCamera(i / 60) - const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height) - const visible = visibleChunks(level.chunks, viewProj) - const mobDraws = visibleMobs(level.mobs, viewProj) - renderer.dispatch(camera, viewProj, visible, i / 60, mobDraws) + const viewProj = Camera.viewProjection( + camera, + renderer.fb.width / renderer.fb.height, + ) + const visible = RenderScene.visibleChunks(level.render, viewProj) + const instances = Level.visibleInstances(level, viewProj) + renderer.dispatch(camera, viewProj, visible, i / 60, instances) inFlight = true if (renderer.done() && record()) { report() @@ -322,29 +350,12 @@ function runBench( requestAnimationFrame(tick) } -/** Rebuild the dynamic tail of `level.colliders`: keep the static prefix, then add - * a block/stand-on AABB for each mob near the player. Mobs move, so these can't be - * baked. Only mobs on the ground are `standable` (hop onto a resting frog/perched - * robin); bees and airborne birds still block but never make a mid-air platform. - * Only mobs within `MOB_COLLIDE_RANGE` are added -- the rest can't be reached this - * frame anyway. */ -function rebuildMobColliders(level: Level, playerPos: Vec3, staticCount: number): void { - level.colliders.length = staticCount - for (const m of level.mobs) { - const dx = m.position.x - playerPos.x - const dz = m.position.z - playerPos.z - if (dx * dx + dz * dz > MOB_COLLIDE_RANGE * MOB_COLLIDE_RANGE) { - continue - } - const half = Mob.boundingRadius(m.kind) * m.scale * 0.7 - level.colliders.push({ - minX: m.position.x - half, - maxX: m.position.x + half, - minZ: m.position.z - half, - maxZ: m.position.z + half, - top: m.position.y + Mob.bodyHeight(m.kind) * m.scale, - standable: m.kind !== "bee" && m.grounded, - }) +function readPlayerInput(keys: Set): CharacterInput { + return { + forward: (keys.has("KeyW") ? 1 : 0) - (keys.has("KeyS") ? 1 : 0), + right: (keys.has("KeyD") ? 1 : 0) - (keys.has("KeyA") ? 1 : 0), + jump: keys.has("Space"), + run: keys.has("ShiftLeft") || keys.has("ShiftRight"), } } diff --git a/app/render-worker.ts b/app/render-worker.ts index 51e0575..7610fb9 100644 --- a/app/render-worker.ts +++ b/app/render-worker.ts @@ -1,35 +1,12 @@ import type { Framebuffer } from "../engine/render/Framebuffer" -import type { RenderConfig } from "../engine/render/RenderConfig" -import { MOB_KINDS } from "../game/actors/Mob" -import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "../game/renderScene" - -/** One-time setup: shared framebuffer + control/param buffers, the (cloned) - * scene, this worker's row band, and its index into the per-worker times array. */ -type Init = { - colorSAB: SharedArrayBuffer - depthSAB: SharedArrayBuffer - width: number - height: number - scene: Scene - band: [number, number] - config: RenderConfig - skyStep: number - ctrlSAB: SharedArrayBuffer - camSAB: SharedArrayBuffer - vpSAB: SharedArrayBuffer - visSAB: SharedArrayBuffer - mobSAB: SharedArrayBuffer - timesSAB: SharedArrayBuffer - index: number -} - -const FRAME = 0 -const DONE = 1 -const VIS = 2 -const MOBVIS = 3 +import { RenderProtocol, type RenderWorkerInit } from "../engine/render/RenderProtocol" +import { RenderScene, type RenderInstance } from "../engine/render/RenderScene" const ctx = globalThis as unknown as { - addEventListener: (type: "message", handler: (e: { data: Init }) => void) => void + addEventListener: ( + type: "message", + handler: (e: { data: RenderWorkerInit }) => void, + ) => void } ctx.addEventListener("message", (e) => { @@ -41,31 +18,46 @@ ctx.addEventListener("message", (e) => { depth: new Float32Array(m.depthSAB), } const ctrl = new Int32Array(m.ctrlSAB) - const cam = new Float64Array(m.camSAB) - const vp = new Float32Array(m.vpSAB) - const vis = new Int32Array(m.visSAB) - const mob = new Float32Array(m.mobSAB) + const cameraData = new Float64Array(m.cameraSAB) + const viewProjection = new Float32Array(m.viewProjectionSAB) + const visibleChunks = new Int32Array(m.visibleChunkSAB) + const instanceIds = new Int32Array(m.instanceIdSAB) + const instanceTransforms = new Float32Array(m.instanceTransformSAB) const times = new Float64Array(m.timesSAB) - const { scene, band, config, skyStep, index } = m + const { scene, band, config, skyStep, workerIndex } = m + const instances: RenderInstance[] = [] // Lock-free frame loop: block until main bumps the frame counter, render this // band, record the band time, and signal done. No messages per frame. let last = 0 for (;;) { - Atomics.wait(ctrl, FRAME, last) - last = Atomics.load(ctrl, FRAME) + Atomics.wait(ctrl, RenderProtocol.FRAME, last) + last = Atomics.load(ctrl, RenderProtocol.FRAME) const t0 = performance.now() - const camera = { position: { x: cam[0], y: cam[1], z: cam[2] }, yaw: cam[3], pitch: cam[4], fov: cam[5] } - const count = Atomics.load(ctrl, VIS) - const visible = [...vis.subarray(0, count)] - const mobCount = Atomics.load(ctrl, MOBVIS) - const mobDraws: MobDraw[] = [] - for (let i = 0; i < mobCount; i++) { - const o = i * MOB_FLOATS - mobDraws.push({ kind: MOB_KINDS[mob[o]] ?? "frog", x: mob[o + 1], y: mob[o + 2], z: mob[o + 3], heading: mob[o + 4], scale: mob[o + 5] }) - } - renderBand(fb, scene, camera, vp, visible, mobDraws, config, skyStep, cam[6], band[0], band[1]) - times[index] = performance.now() - t0 - Atomics.add(ctrl, DONE, 1) + const frameCamera = RenderProtocol.readCamera(cameraData) + const count = Atomics.load(ctrl, RenderProtocol.VISIBLE_CHUNKS) + const visible = [...visibleChunks.subarray(0, count)] + const instanceCount = Atomics.load(ctrl, RenderProtocol.VISIBLE_INSTANCES) + RenderProtocol.readInstances( + instanceIds, + instanceTransforms, + instanceCount, + instances, + ) + RenderScene.renderBand( + fb, + scene, + frameCamera.camera, + viewProjection, + visible, + instances, + config, + skyStep, + frameCamera.time, + band[0], + band[1], + ) + times[workerIndex] = performance.now() - t0 + Atomics.add(ctrl, RenderProtocol.DONE, 1) } }) diff --git a/app/renderer.ts b/app/renderer.ts index 254a6f6..3fca7f2 100644 --- a/app/renderer.ts +++ b/app/renderer.ts @@ -2,8 +2,8 @@ import { Framebuffer } from "../engine/render/Framebuffer" import type { RenderConfig } from "../engine/render/RenderConfig" import type { Mat4 } from "../engine/math/Mat4" import type { Camera } from "../engine/scene/Camera" -import { MOB_KINDS } from "../game/actors/Mob" -import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "../game/renderScene" +import { RenderProtocol, type RenderWorkerInit } from "../engine/render/RenderProtocol" +import { RenderScene, type RenderInstance } from "../engine/render/RenderScene" /** Clouds are drawn at 1/SKY_STEP resolution (the sky base + sun stay per-pixel); * band splits align to it so the cloud block grid stays seamless across workers. */ @@ -19,12 +19,6 @@ const ENABLE_WORKERS = true * bench. */ const MAX_WORKERS = 3 -// Indices into the shared control Int32Array. -const FRAME = 0 // bumped by main to dispatch a frame -const DONE = 1 // workers add 1 when their band is finished -const VIS = 2 // number of visible chunk indices this frame -const MOBVIS = 3 // number of visible mobs this frame - /** * Render driver. When the page is cross-origin-isolated it runs a pool of worker * threads, each owning a disjoint row band of a `SharedArrayBuffer` framebuffer; @@ -41,18 +35,37 @@ export type Renderer = { readonly parallel: boolean reconfigure: (config: RenderConfig) => void /** Start rendering one frame (non-blocking in the worker path). */ - dispatch: (camera: Camera, viewProj: Mat4, visible: number[], time: number, mobDraws: MobDraw[]) => void + dispatch: ( + camera: Camera, + viewProj: Mat4, + visible: number[], + time: number, + instances: RenderInstance[], + ) => void /** Has the dispatched frame finished? (always true single-threaded.) */ done: () => boolean /** Critical-path render time of the last frame, ms (max band time / inline time). */ workMs: () => number } -export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers?: boolean): Renderer { - const hw = (globalThis.navigator as Navigator | undefined)?.hardwareConcurrency ?? 4 +type PendingFrame = { + camera: Camera + viewProjection: Mat4 + visibleChunks: number[] + instances: RenderInstance[] + time: number +} + +export function createRenderer( + scene: RenderScene, + initial: RenderConfig, + forceWorkers?: boolean, +): Renderer { + const hw = + (globalThis.navigator as Navigator | undefined)?.hardwareConcurrency ?? 4 const workerCount = Math.max(1, Math.min(MAX_WORKERS, hw - 1)) const maxVis = Math.max(1, scene.chunks.length) - const maxMobs = Math.max(1, scene.mobCount) + const maxInstances = Math.max(1, scene.maxInstances) let config = initial const want = forceWorkers ?? ENABLE_WORKERS let parallel = want && canShare() @@ -63,58 +76,116 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers let cam: Float64Array = new Float64Array(0) // pos x/y/z, yaw, pitch, fov, time let vp: Float32Array = new Float32Array(0) // the view-projection matrix let vis: Int32Array = new Int32Array(0) // visible chunk indices - let mob: Float32Array = new Float32Array(0) // visible mob transforms (MOB_FLOATS each) + let instanceIds: Int32Array = new Int32Array(0) + let instanceTransforms: Float32Array = new Float32Array(0) let times: Float64Array = new Float64Array(0) // per-worker band render ms let lastWork = 0 + let generation = 0 + let pending: PendingFrame | null = null - function setup(): void { - for (const w of workers) { - w.terminate() + function stopWorkers(): void { + for (const worker of workers) { + worker.terminate() } workers = [] + } + + function renderInline(frame: PendingFrame): void { + const t0 = performance.now() + RenderScene.renderBand( + fb, + scene, + frame.camera, + frame.viewProjection, + frame.visibleChunks, + frame.instances, + config, + SKY_STEP, + frame.time, + 0, + fb.height, + ) + lastWork = performance.now() - t0 + } + + function disableParallel(currentGeneration: number): void { + if (!parallel || currentGeneration !== generation) { + return + } + const frame = pending + parallel = false + stopWorkers() + if (frame !== null) { + renderInline(frame) + pending = null + } + } + + function setup(): void { + generation++ + const currentGeneration = generation + pending = null + stopWorkers() const width = config.internalWidth const height = config.internalHeight if (parallel) { const n = width * height - fb = { width, height, color: new Uint32Array(new SharedArrayBuffer(n * 4)), depth: new Float32Array(new SharedArrayBuffer(n * 4)) } + fb = { + width, + height, + color: new Uint32Array(new SharedArrayBuffer(n * 4)), + depth: new Float32Array(new SharedArrayBuffer(n * 4)), + } const bands = splitBands(height, workerCount, SKY_STEP) - ctrl = new Int32Array(new SharedArrayBuffer(4 * 4)) - cam = new Float64Array(new SharedArrayBuffer(7 * 8)) - vp = new Float32Array(new SharedArrayBuffer(16 * 4)) + ctrl = new Int32Array( + new SharedArrayBuffer(RenderProtocol.CONTROL_LENGTH * 4), + ) + cam = new Float64Array( + new SharedArrayBuffer(RenderProtocol.CAMERA_LENGTH * 8), + ) + vp = new Float32Array( + new SharedArrayBuffer(RenderProtocol.VIEW_PROJECTION_LENGTH * 4), + ) vis = new Int32Array(new SharedArrayBuffer(maxVis * 4)) - mob = new Float32Array(new SharedArrayBuffer(maxMobs * MOB_FLOATS * 4)) + instanceIds = new Int32Array(new SharedArrayBuffer(maxInstances * 4)) + instanceTransforms = new Float32Array( + new SharedArrayBuffer( + maxInstances * RenderProtocol.TRANSFORM_FLOATS * 4, + ), + ) times = new Float64Array(new SharedArrayBuffer(bands.length * 8)) try { bands.forEach((band, index) => { - const worker = new Worker(new URL("./render-worker.ts", import.meta.url), { type: "module" }) - worker.addEventListener("error", () => { - parallel = false - }) - worker.postMessage({ - colorSAB: fb.color.buffer, - depthSAB: fb.depth.buffer, + const worker = new Worker( + new URL("./render-worker.ts", import.meta.url), + { type: "module" }, + ) + worker.addEventListener("error", () => disableParallel(currentGeneration)) + const init: RenderWorkerInit = { + colorSAB: shared(fb.color.buffer), + depthSAB: shared(fb.depth.buffer), width, height, scene, band, config, skyStep: SKY_STEP, - ctrlSAB: ctrl.buffer, - camSAB: cam.buffer, - vpSAB: vp.buffer, - visSAB: vis.buffer, - mobSAB: mob.buffer, - timesSAB: times.buffer, - index, - }) + ctrlSAB: shared(ctrl.buffer), + cameraSAB: shared(cam.buffer), + viewProjectionSAB: shared(vp.buffer), + visibleChunkSAB: shared(vis.buffer), + instanceIdSAB: shared(instanceIds.buffer), + instanceTransformSAB: shared(instanceTransforms.buffer), + timesSAB: shared(times.buffer), + workerIndex: index, + } + worker.postMessage(init) workers.push(worker) }) + Atomics.store(ctrl, RenderProtocol.DONE, workers.length) } catch { parallel = false - for (const w of workers) { - w.terminate() - } - workers = [] + stopWorkers() } } if (!parallel || workers.length === 0) { @@ -134,44 +205,44 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers config = next setup() }, - dispatch(camera, viewProj, visible, time, mobDraws) { + dispatch(camera, viewProj, visible, time, instances) { + pending = { + camera, + viewProjection: viewProj, + visibleChunks: visible, + instances, + time, + } if (parallel && workers.length > 0) { - cam[0] = camera.position.x - cam[1] = camera.position.y - cam[2] = camera.position.z - cam[3] = camera.yaw - cam[4] = camera.pitch - cam[5] = camera.fov - cam[6] = time - vp.set(viewProj) + RenderProtocol.writeCamera(cam, camera, time) + RenderProtocol.writeViewProjection(vp, viewProj) const count = Math.min(visible.length, vis.length) for (let i = 0; i < count; i++) { vis[i] = visible[i] } - const mobCount = Math.min(mobDraws.length, maxMobs) - for (let i = 0; i < mobCount; i++) { - const d = mobDraws[i] - const o = i * MOB_FLOATS - mob[o] = MOB_KINDS.indexOf(d.kind) - mob[o + 1] = d.x - mob[o + 2] = d.y - mob[o + 3] = d.z - mob[o + 4] = d.heading - mob[o + 5] = d.scale - } - Atomics.store(ctrl, VIS, count) - Atomics.store(ctrl, MOBVIS, mobCount) - Atomics.store(ctrl, DONE, 0) - Atomics.add(ctrl, FRAME, 1) - Atomics.notify(ctrl, FRAME, workers.length) + const instanceCount = RenderProtocol.writeInstances( + instanceIds, + instanceTransforms, + instances, + ) + Atomics.store(ctrl, RenderProtocol.VISIBLE_CHUNKS, count) + Atomics.store(ctrl, RenderProtocol.VISIBLE_INSTANCES, instanceCount) + Atomics.store(ctrl, RenderProtocol.DONE, 0) + Atomics.add(ctrl, RenderProtocol.FRAME, 1) + Atomics.notify(ctrl, RenderProtocol.FRAME, workers.length) return } - const t0 = performance.now() - renderBand(fb, scene, camera, viewProj, visible, mobDraws, config, SKY_STEP, time, 0, fb.height) - lastWork = performance.now() - t0 + renderInline(pending) + pending = null }, done() { - return !(parallel && workers.length > 0) || Atomics.load(ctrl, DONE) >= workers.length + const complete = + !(parallel && workers.length > 0) || + Atomics.load(ctrl, RenderProtocol.DONE) >= workers.length + if (complete) { + pending = null + } + return complete }, workMs() { if (parallel && workers.length > 0) { @@ -194,20 +265,33 @@ function canShare(): boolean { return ( typeof SharedArrayBuffer !== "undefined" && typeof Worker !== "undefined" && - (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true + (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === + true ) } +function shared(buffer: ArrayBufferLike): SharedArrayBuffer { + if (!(buffer instanceof SharedArrayBuffer)) { + throw new Error("render worker buffer is not shared") + } + return buffer +} + /** Split `height` rows into ~`count` bands. Interior boundaries snap up to a * multiple of `step` so the sky's block grid stays aligned (no seam), while the * bands stay disjoint so no two workers write the same pixel. */ -function splitBands(height: number, count: number, step: number): [number, number][] { +function splitBands( + height: number, + count: number, + step: number, +): [number, number][] { const bands: [number, number][] = [] const per = Math.ceil(height / count) let y = 0 while (y < height) { const raw = y + per - const y1 = raw >= height ? height : Math.min(height, Math.ceil(raw / step) * step) + const y1 = + raw >= height ? height : Math.min(height, Math.ceil(raw / step) * step) bands.push([y, y1]) y = y1 } diff --git a/docs/adr/0001-engine-owns-world-concepts.md b/docs/adr/0001-engine-owns-world-concepts.md new file mode 100644 index 0000000..bcda3b7 --- /dev/null +++ b/docs/adr/0001-engine-owns-world-concepts.md @@ -0,0 +1,3 @@ +# Engine owns world concepts + +Engine owns contracts and reusable mechanisms for levels, terrain, actors, prefabs, collision, scene rendering, chunking, LOD, and render transport; game owns concrete level values and concrete actor or prefab definitions. Game content is referenced through definition objects, never closed `MobKind`, `TreeKind`, or equivalent content registries; finite engine capability unions remain allowed. A live `Level` may contain behavior, while its `RenderScene` projection contains only clone-safe data for workers, with numeric prototype indexes confined to engine transport. diff --git a/engine/math/Mat4.ts b/engine/math/Mat4.ts index 7848e3c..9686715 100644 --- a/engine/math/Mat4.ts +++ b/engine/math/Mat4.ts @@ -22,7 +22,7 @@ export namespace Mat4 { /** Model transform T * Ry * S: uniform `scale`, then a yaw rotation about Y, * then a translation. Built directly in column-major storage (no intermediate - * matmuls) since it runs per mob per frame. A vertex at local +Z ends up + * matmuls) since it runs per instance per frame. A vertex at local +Z ends up * pointing along world (sin yaw, 0, cos yaw), i.e. the object faces `yaw`. */ export function compose(tx: number, ty: number, tz: number, yaw: number, scale: number): Mat4 { const c = Math.cos(yaw) diff --git a/engine/render/Chunk.ts b/engine/render/Chunk.ts new file mode 100644 index 0000000..b2b9f2d --- /dev/null +++ b/engine/render/Chunk.ts @@ -0,0 +1,29 @@ +import type { DrawGroup } from "./Material" +import type { Vec3 } from "../math/Vec3" + +export type Bounds3 = { + minX: number + minY: number + minZ: number + maxX: number + maxY: number + maxZ: number +} + +/** One cullable section of static world geometry with two engine-supported LODs. */ +export type Chunk = Bounds3 & { + readonly near: readonly DrawGroup[] + readonly far: readonly DrawGroup[] +} + +export namespace Chunk { + export function isFar(chunk: Chunk, eye: Vec3, lodDistance: number): boolean { + if (!(lodDistance < Infinity)) { + return false + } + const dx = eye.x - Math.max(chunk.minX, Math.min(chunk.maxX, eye.x)) + const dy = eye.y - Math.max(chunk.minY, Math.min(chunk.maxY, eye.y)) + const dz = eye.z - Math.max(chunk.minZ, Math.min(chunk.maxZ, eye.z)) + return dx * dx + dy * dy + dz * dz > lodDistance * lodDistance + } +} diff --git a/engine/render/ChunkBuilder.ts b/engine/render/ChunkBuilder.ts new file mode 100644 index 0000000..6bf1486 --- /dev/null +++ b/engine/render/ChunkBuilder.ts @@ -0,0 +1,123 @@ +import { STRIDE, type Mesh } from "../scene/Mesh" +import type { Chunk, Bounds3 } from "./Chunk" +import type { DrawGroup, Material } from "./Material" + +export type MeshBatch = { + mesh: (material: Material) => Mesh + use: (material: Material, mesh: Mesh) => void +} + +export type ChunkItem = { + position: { x: number; z: number } + bakeNear: (batch: MeshBatch) => void + bakeFar?: (batch: MeshBatch) => void +} + +export type ChunkCell = { + x0: number + z0: number + x1: number + z1: number +} + +export type ChunkBuilder = { + minX: number + minZ: number + maxX: number + maxZ: number + columns: number + rows: number + bakeCell: (near: MeshBatch, far: MeshBatch, cell: ChunkCell) => void +} + +export namespace ChunkBuilder { + export function build(config: ChunkBuilder, items: ChunkItem[]): Chunk[] { + const width = (config.maxX - config.minX) / config.columns + const depth = (config.maxZ - config.minZ) / config.rows + const chunks: Chunk[] = [] + for (let column = 0; column < config.columns; column++) { + const x0 = config.minX + column * width + const x1 = x0 + width + for (let row = 0; row < config.rows; row++) { + const z0 = config.minZ + row * depth + const z1 = z0 + depth + const nearMeshes = new Map() + const farMeshes = new Map() + const near = batch(nearMeshes) + const far = batch(farMeshes) + config.bakeCell(near, far, { x0, z0, x1, z1 }) + for (const item of items) { + if (inCell(item.position, x0, z0, x1, z1)) { + item.bakeNear(near) + item.bakeFar?.(far) + } + } + const nearGroups = groups(nearMeshes) + const farGroups = groups(farMeshes) + const box = bounds([...nearMeshes.values(), ...farMeshes.values()]) + if (box !== null) { + chunks.push({ ...box, near: nearGroups, far: farGroups }) + } + } + } + return chunks + } + + function batch(meshes: Map): MeshBatch { + return { + mesh(material) { + let mesh = meshes.get(material) + if (mesh === undefined) { + mesh = { verts: [], indices: [] } + meshes.set(material, mesh) + } + return mesh + }, + use(material, mesh) { + meshes.set(material, mesh) + }, + } + } + + function groups(meshes: Map): DrawGroup[] { + const result: DrawGroup[] = [] + for (const [material, mesh] of meshes) { + if (mesh.indices.length > 0) { + result.push({ mesh, material }) + } + } + return result + } + + function inCell( + position: { x: number; z: number }, + x0: number, + z0: number, + x1: number, + z1: number, + ): boolean { + return ( + position.x >= x0 && position.x < x1 && position.z >= z0 && position.z < z1 + ) + } + + function bounds(meshes: Mesh[]): Bounds3 | null { + let minX = Infinity + let minY = Infinity + let minZ = Infinity + let maxX = -Infinity + let maxY = -Infinity + let maxZ = -Infinity + for (const mesh of meshes) { + for (let i = 0; i < mesh.verts.length; i += STRIDE) { + minX = Math.min(minX, mesh.verts[i]) + minY = Math.min(minY, mesh.verts[i + 1]) + minZ = Math.min(minZ, mesh.verts[i + 2]) + maxX = Math.max(maxX, mesh.verts[i]) + maxY = Math.max(maxY, mesh.verts[i + 1]) + maxZ = Math.max(maxZ, mesh.verts[i + 2]) + } + } + return maxX < minX ? null : { minX, minY, minZ, maxX, maxY, maxZ } + } +} diff --git a/engine/render/Rasterizer.ts b/engine/render/Rasterizer.ts index 68da0b3..edbf202 100644 --- a/engine/render/Rasterizer.ts +++ b/engine/render/Rasterizer.ts @@ -115,8 +115,8 @@ export namespace Rasterizer { * NEAR_W) with a single Sutherland-Hodgman pass, writing the result (0, 3, or * 4 verts) to `dst` and returning its vertex count. * - * This matters even when standing inside the room: a wall to your side has - * vertices both in front of and behind the eye. Without clipping, the behind + * Geometry intersecting the camera plane has vertices both in front of and + * behind the eye. Without clipping, the behind * vertices have w <= 0 and invert under the perspective divide, smearing the * triangle across the whole screen (and risking divide-by-zero). */ @@ -201,7 +201,7 @@ export namespace Rasterizer { return } // Backface cull: a back-facing triangle has positive area here. Only for - // solid, consistently-wound meshes; sprites/room stay double-sided. + // solid, consistently-wound meshes; other materials may stay double-sided. if (cull && area > 0) { return } diff --git a/engine/render/RenderConfig.ts b/engine/render/RenderConfig.ts index ed147f9..1379544 100644 --- a/engine/render/RenderConfig.ts +++ b/engine/render/RenderConfig.ts @@ -46,10 +46,8 @@ export type RenderConfig = { * short draw distance and the shimmer of far geometry. It also colors pixels * no triangle covers, so the frame's clear color should match `fog.color`. */ fog: Fog | null - /** Beyond this distance (world units) trees + boulders draw as cheap low-poly - * impostors instead of full geometry, cutting per-triangle work in dense - * views. Kept inside `fog.far` so far detail is already fog-dimmed at the - * switch; `Infinity` disables LOD. */ + /** Beyond this distance (world units), chunks draw their cheaper far groups + * instead of near geometry. `Infinity` disables LOD. */ lodDistance: number } diff --git a/engine/render/RenderProtocol.ts b/engine/render/RenderProtocol.ts new file mode 100644 index 0000000..f497c14 --- /dev/null +++ b/engine/render/RenderProtocol.ts @@ -0,0 +1,123 @@ +import type { Mat4 } from "../math/Mat4" +import type { Camera } from "../scene/Camera" +import type { RenderConfig } from "./RenderConfig" +import type { RenderInstance, RenderScene } from "./RenderScene" + +export type RenderWorkerInit = { + colorSAB: SharedArrayBuffer + depthSAB: SharedArrayBuffer + width: number + height: number + scene: RenderScene + band: [number, number] + config: RenderConfig + skyStep: number + ctrlSAB: SharedArrayBuffer + cameraSAB: SharedArrayBuffer + viewProjectionSAB: SharedArrayBuffer + visibleChunkSAB: SharedArrayBuffer + instanceIdSAB: SharedArrayBuffer + instanceTransformSAB: SharedArrayBuffer + timesSAB: SharedArrayBuffer + workerIndex: number +} + +export type RenderFrameCamera = { + camera: Camera + time: number +} + +/** Shared frame-buffer layout used by browser driver and render workers. */ +export namespace RenderProtocol { + export const FRAME = 0 + export const DONE = 1 + export const VISIBLE_CHUNKS = 2 + export const VISIBLE_INSTANCES = 3 + export const CONTROL_LENGTH = 4 + export const CAMERA_X = 0 + export const CAMERA_Y = 1 + export const CAMERA_Z = 2 + export const CAMERA_YAW = 3 + export const CAMERA_PITCH = 4 + export const CAMERA_FOV = 5 + export const CAMERA_TIME = 6 + export const CAMERA_LENGTH = 7 + export const VIEW_PROJECTION_LENGTH = 16 + export const TRANSFORM_FLOATS = 5 + + export function writeCamera( + output: Float64Array, + camera: Camera, + time: number, + ): void { + output[CAMERA_X] = camera.position.x + output[CAMERA_Y] = camera.position.y + output[CAMERA_Z] = camera.position.z + output[CAMERA_YAW] = camera.yaw + output[CAMERA_PITCH] = camera.pitch + output[CAMERA_FOV] = camera.fov + output[CAMERA_TIME] = time + } + + export function readCamera(input: Float64Array): RenderFrameCamera { + return { + camera: { + position: { + x: input[CAMERA_X], + y: input[CAMERA_Y], + z: input[CAMERA_Z], + }, + yaw: input[CAMERA_YAW], + pitch: input[CAMERA_PITCH], + fov: input[CAMERA_FOV], + }, + time: input[CAMERA_TIME], + } + } + + export function writeViewProjection( + output: Float32Array, + viewProjection: Mat4, + ): void { + output.set(viewProjection) + } + + export function writeInstances( + ids: Int32Array, + transforms: Float32Array, + instances: RenderInstance[], + ): number { + const count = Math.min(instances.length, ids.length) + for (let i = 0; i < count; i++) { + const instance = instances[i] + const offset = i * TRANSFORM_FLOATS + ids[i] = instance.prototype + transforms[offset] = instance.x + transforms[offset + 1] = instance.y + transforms[offset + 2] = instance.z + transforms[offset + 3] = instance.heading + transforms[offset + 4] = instance.scale + } + return count + } + + export function readInstances( + ids: Int32Array, + transforms: Float32Array, + count: number, + output: RenderInstance[], + ): void { + output.length = 0 + for (let i = 0; i < count; i++) { + const offset = i * TRANSFORM_FLOATS + output.push({ + prototype: ids[i], + x: transforms[offset], + y: transforms[offset + 1], + z: transforms[offset + 2], + heading: transforms[offset + 3], + scale: transforms[offset + 4], + }) + } + } +} diff --git a/engine/render/RenderScene.ts b/engine/render/RenderScene.ts new file mode 100644 index 0000000..225567f --- /dev/null +++ b/engine/render/RenderScene.ts @@ -0,0 +1,224 @@ +import { Mat4 as Matrix, type Mat4 } from "../math/Mat4" +import type { Vec2 } from "../math/Vec2" +import type { Vec3 } from "../math/Vec3" +import type { Camera } from "../scene/Camera" +import { Sprite } from "../scene/Sprite" +import { Framebuffer, type Framebuffer as Frame } from "./Framebuffer" +import { Frustum } from "./Frustum" +import type { DrawGroup, Material } from "./Material" +import { Rasterizer } from "./Rasterizer" +import type { RenderConfig } from "./RenderConfig" +import { Sky, type SkyConfig } from "./Sky" +import { Chunk, type Chunk as RenderChunk } from "./Chunk" + +export type Billboard = { + position: Vec3 + size: Vec2 + material: Material +} + +export type RenderPrototype = { + readonly groups: readonly DrawGroup[] + readonly radius: number + readonly minY: number + readonly maxY: number +} + +export type RenderTransform = { + x: number + y: number + z: number + heading: number + scale: number +} + +export type RenderInstance = RenderTransform & { + prototype: number +} + +/** Clone-safe render projection of a live level. Contains no behavior callbacks or + * game-content registries, so workers need only engine code. */ +export type RenderScene = { + readonly chunks: readonly RenderChunk[] + readonly staticGroups: readonly DrawGroup[] + readonly billboards: readonly Billboard[] + readonly prototypes: readonly RenderPrototype[] + readonly maxInstances: number + readonly sky: SkyConfig +} + +export namespace RenderScene { + export function visibleChunks( + scene: RenderScene, + viewProjection: Mat4, + ): number[] { + const frustum = Frustum.fromViewProj(viewProjection) + const visible: number[] = [] + for (let i = 0; i < scene.chunks.length; i++) { + const chunk = scene.chunks[i] + if ( + Frustum.intersectsAabb( + frustum, + chunk.minX, + chunk.minY, + chunk.minZ, + chunk.maxX, + chunk.maxY, + chunk.maxZ, + ) + ) { + visible.push(i) + } + } + return visible + } + + export function visibleInstances( + scene: RenderScene, + instances: RenderInstance[], + viewProjection: Mat4, + ): RenderInstance[] { + const frustum = Frustum.fromViewProj(viewProjection) + const visible: RenderInstance[] = [] + for (const instance of instances) { + const prototype = scene.prototypes[instance.prototype] + if (prototype === undefined) { + continue + } + const radius = prototype.radius * instance.scale + if ( + Frustum.intersectsAabb( + frustum, + instance.x - radius, + instance.y + prototype.minY * instance.scale, + instance.z - radius, + instance.x + radius, + instance.y + prototype.maxY * instance.scale, + instance.z + radius, + ) + ) { + visible.push(instance) + } + } + return visible + } + + export function renderBand( + framebuffer: Frame, + scene: RenderScene, + camera: Camera, + viewProjection: Mat4, + visible: number[], + instances: RenderInstance[], + config: RenderConfig, + skyStep: number, + time: number, + y0: number, + y1: number, + ): void { + Sky.render(framebuffer, camera, scene.sky, time, skyStep, y0, y1) + drawGroups(framebuffer, scene.staticGroups, viewProjection, config, y0, y1) + for (const index of visible) { + const chunk = scene.chunks[index] + const groups = Chunk.isFar(chunk, camera.position, config.lodDistance) + ? chunk.far + : chunk.near + drawGroups(framebuffer, groups, viewProjection, config, y0, y1) + } + for (const billboard of scene.billboards) { + const sprite = { + position: billboard.position, + size: billboard.size, + texture: billboard.material.texture, + } + Rasterizer.draw( + framebuffer, + Sprite.billboard(sprite, camera), + billboard.material.texture, + viewProjection, + config, + billboard.material.cull, + y0, + y1, + ) + } + for (const instance of instances) { + const prototype = scene.prototypes[instance.prototype] + if (prototype === undefined) { + continue + } + const modelViewProjection = Matrix.multiply( + viewProjection, + Matrix.compose( + instance.x, + instance.y, + instance.z, + instance.heading, + instance.scale, + ), + ) + drawGroups( + framebuffer, + prototype.groups, + modelViewProjection, + config, + y0, + y1, + ) + } + Framebuffer.quantize(framebuffer, config, y0, y1) + } + + export function triangleCount( + scene: RenderScene, + visible: number[], + instances: RenderInstance[], + eye: Vec3, + lodDistance: number, + ): number { + let indices = groupIndices(scene.staticGroups) + scene.billboards.length * 6 + for (const index of visible) { + const chunk = scene.chunks[index] + indices += groupIndices( + Chunk.isFar(chunk, eye, lodDistance) ? chunk.far : chunk.near, + ) + } + for (const instance of instances) { + const prototype = scene.prototypes[instance.prototype] + if (prototype !== undefined) { + indices += groupIndices(prototype.groups) + } + } + return (indices / 3) | 0 + } + + function drawGroups( + framebuffer: Frame, + groups: readonly DrawGroup[], + matrix: Mat4, + config: RenderConfig, + y0: number, + y1: number, + ): void { + for (const group of groups) { + Rasterizer.draw( + framebuffer, + group.mesh, + group.material.texture, + matrix, + config, + group.material.cull, + y0, + y1, + ) + } + } + + function groupIndices(groups: readonly DrawGroup[]): number { + let count = 0 + for (const group of groups) { + count += group.mesh.indices.length + } + return count + } +} diff --git a/engine/scene/Actor.ts b/engine/scene/Actor.ts index 3318786..4e2729c 100644 --- a/engine/scene/Actor.ts +++ b/engine/scene/Actor.ts @@ -1,26 +1,48 @@ -import type { Mesh } from "./Mesh" +import type { RenderPrototype, RenderTransform } from "../render/RenderScene" +import type { Collider } from "../world/Collider" -/** Definition of an **Entity** actor kind: something that lives in the world with - * its own behavior and a live transform (mobs, and later the npc / powerups) -- as - * opposed to a baked, static `Prop`. `State` is the per-instance runtime record the - * behavior mutates; `World` is whatever that behavior reads (e.g. `Terrain`). - * - * Every field is plain data or a module function, so a definition is **imported - * into each context** (main thread + each render worker) rather than structured- - * cloned across the wire -- the per-kind polymorphism is code, not serialized - * state. That's what lets a registry of these stay compatible with the worker - * renderer (only plain instance data ever crosses; behavior is loaded per side). */ -export type Entity = { - /** Stable tag for the kind (also the texture key today). The registry's order, - * not this string, is what becomes the id packed into the mob SAB. */ - name: string - /** Build the canonical local-space mesh once; every instance shares it, differing - * only by its per-frame model matrix. */ - build: (mesh: Mesh) => void - /** Advance one instance by `dt` seconds. */ +/** Open actor behavior and representation. Concrete definitions are ordinary game + * objects referenced directly by instances. */ +export type ActorDefinition = { + prototype: RenderPrototype update: (state: State, dt: number, world: World) => void - /** Local bounding radius (pre-scale) for the per-frame cull AABB. */ - boundingRadius: number - /** Local body height (pre-scale) for the top of the stand-on collider. */ - bodyHeight: number + transform: (state: State) => RenderTransform + collider?: (state: State, world: World) => Collider | null +} + +/** Type-erased live actor. `create` captures concrete state safely, allowing one + * level to hold unrelated actor state types without a content union. */ +export type Actor = { + readonly definition: object + readonly prototype: RenderPrototype + readonly updateState: (dt: number, world: World) => void + readonly readTransform: () => RenderTransform + readonly readCollider: (world: World) => Collider | null +} + +export namespace Actor { + export function create( + definition: ActorDefinition, + state: State, + ): Actor { + return { + definition, + prototype: definition.prototype, + updateState: (dt, world) => definition.update(state, dt, world), + readTransform: () => definition.transform(state), + readCollider: (world) => definition.collider?.(state, world) ?? null, + } + } + + export function update(actor: Actor, dt: number, world: World): void { + actor.updateState(dt, world) + } + + export function transform(actor: Actor): RenderTransform { + return actor.readTransform() + } + + export function collider(actor: Actor, world: World): Collider | null { + return actor.readCollider(world) + } } diff --git a/engine/scene/MeshBuilder.ts b/engine/scene/MeshBuilder.ts new file mode 100644 index 0000000..66b5249 --- /dev/null +++ b/engine/scene/MeshBuilder.ts @@ -0,0 +1,80 @@ +import { STRIDE, type Mesh } from "./Mesh" + +type Corner = [number, number, number] + +export namespace MeshBuilder { + export function quad( + mesh: Mesh, + a: Corner, + b: Corner, + c: Corner, + d: Corner, + uScale: number, + vScale: number, + ): void { + const base = mesh.verts.length / STRIDE + mesh.verts.push( + a[0], + a[1], + a[2], + 0, + 0, + b[0], + b[1], + b[2], + uScale, + 0, + c[0], + c[1], + c[2], + uScale, + vScale, + d[0], + d[1], + d[2], + 0, + vScale, + ) + mesh.indices.push(base, base + 1, base + 2, base, base + 2, base + 3) + } + + export function slab( + mesh: Mesh, + x0: number, + x1: number, + z0: number, + z1: number, + y0: number, + y1: number, + tilesPerUnit: number, + ): void { + const dx = (x1 - x0) * tilesPerUnit + const dz = (z1 - z0) * tilesPerUnit + const dy = (y1 - y0) * tilesPerUnit + quad(mesh, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], dx, dz) + quad(mesh, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], dx, dy) + quad(mesh, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], dx, dy) + quad(mesh, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], dz, dy) + quad(mesh, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], dz, dy) + } + + export function box( + mesh: Mesh, + centerX: number, + centerZ: number, + half: number, + base: number, + height: number, + ): void { + const x0 = centerX - half + const x1 = centerX + half + const z0 = centerZ - half + const z1 = centerZ + half + const y1 = base + height + quad(mesh, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], 1, 1) + quad(mesh, [x0, base, z0], [x1, base, z0], [x1, y1, z0], [x0, y1, z0], 1, 1) + quad(mesh, [x1, base, z1], [x0, base, z1], [x0, y1, z1], [x1, y1, z1], 1, 1) + quad(mesh, [x1, base, z0], [x1, base, z1], [x1, y1, z1], [x1, y1, z0], 1, 1) + quad(mesh, [x0, base, z1], [x0, base, z0], [x0, y1, z0], [x0, y1, z1], 1, 1) + } +} diff --git a/engine/scene/Prefab.ts b/engine/scene/Prefab.ts new file mode 100644 index 0000000..74674b3 --- /dev/null +++ b/engine/scene/Prefab.ts @@ -0,0 +1,38 @@ +import type { Vec3 } from "../math/Vec3" +import type { MeshBatch } from "../render/ChunkBuilder" +import type { Collider } from "../world/Collider" + +/** Static content recipe. Concrete game prefabs implement this engine contract and + * are referenced as objects, never through a closed content-kind registry. */ +export type Prefab = { + position: (state: State) => Vec3 + bakeNear: (state: State, batch: MeshBatch) => void + bakeFar?: (state: State, batch: MeshBatch) => void + collider?: (state: State) => Collider | null +} + +/** Type-erased placed prefab consumed during level compilation only. */ +export type PlacedPrefab = { + position: Vec3 + bakeNear: (batch: MeshBatch) => void + bakeFar?: (batch: MeshBatch) => void + collider: Collider | null +} + +export namespace Prefab { + export function place( + definition: Prefab, + state: State, + ): PlacedPrefab { + const bakeFar = definition.bakeFar + return { + position: definition.position(state), + bakeNear: (batch) => definition.bakeNear(state, batch), + bakeFar: + bakeFar === undefined + ? undefined + : (batch) => bakeFar(state, batch), + collider: definition.collider?.(state) ?? null, + } + } +} diff --git a/engine/world/CharacterController.ts b/engine/world/CharacterController.ts new file mode 100644 index 0000000..a45e593 --- /dev/null +++ b/engine/world/CharacterController.ts @@ -0,0 +1,89 @@ +import type { Vec3 } from "../math/Vec3" +import { CollisionWorld, type CollisionWorld as World } from "./CollisionWorld" + +export type Character = { + position: Vec3 + yaw: number + velocityY: number + onGround: boolean +} + +export type CharacterInput = { + forward: number + right: number + jump: boolean + run: boolean +} + +export type CharacterConfig = { + radius: number + speed: number + runMultiplier: number + gravity: number + jumpSpeed: number + eyeHeight: number +} + +export namespace CharacterController { + export function update( + character: Character, + input: CharacterInput, + dt: number, + world: World, + config: CharacterConfig, + ): void { + if (input.jump && character.onGround) { + character.velocityY = config.jumpSpeed + character.onGround = false + } + const steps = moveSubsteps(input, dt, config) + for (let i = 0; i < steps; i++) { + moveHorizontal(character, input, dt / steps, config) + CollisionWorld.pushOut(world, character.position, config.radius) + } + character.velocityY -= config.gravity * dt + character.position.y += character.velocityY * dt + const ground = CollisionWorld.groundHeight(world, character.position) + if (character.position.y <= ground) { + character.position.y = ground + character.velocityY = 0 + character.onGround = true + } else { + character.onGround = false + } + } + + function moveSubsteps( + input: CharacterInput, + dt: number, + config: CharacterConfig, + ): number { + const distance = + config.speed * + runFactor(input, config) * + dt * + Math.hypot(input.forward, input.right) + return Math.max(1, Math.ceil(distance / config.radius)) + } + + function moveHorizontal( + character: Character, + input: CharacterInput, + dt: number, + config: CharacterConfig, + ): void { + const speed = config.speed * runFactor(input, config) * dt + const forwardX = Math.sin(character.yaw) + const forwardZ = -Math.cos(character.yaw) + const rightX = Math.cos(character.yaw) + const rightZ = Math.sin(character.yaw) + character.position.x += + (forwardX * input.forward + rightX * input.right) * speed + character.position.z += + (forwardZ * input.forward + rightZ * input.right) * speed + } + + function runFactor(input: CharacterInput, config: CharacterConfig): number { + return input.run ? config.runMultiplier : 1 + } +} diff --git a/engine/world/Collider.ts b/engine/world/Collider.ts new file mode 100644 index 0000000..926916f --- /dev/null +++ b/engine/world/Collider.ts @@ -0,0 +1,116 @@ +import type { Vec3 } from "../math/Vec3" + +/** A 2.5D box: horizontal footprint plus top height. */ +export type BoxCollider = { + shape: "box" + minX: number + maxX: number + minZ: number + maxZ: number + top: number + standable: boolean +} + +/** A 2.5D circle: horizontal footprint plus top height. */ +export type CircleCollider = { + shape: "circle" + x: number + z: number + radius: number + top: number + standable: boolean +} + +/** Finite engine collision capabilities, not game-content identity. */ +export type Collider = BoxCollider | CircleCollider + +export namespace Collider { + export function centerX(collider: Collider): number { + return collider.shape === "circle" + ? collider.x + : (collider.minX + collider.maxX) * 0.5 + } + + export function centerZ(collider: Collider): number { + return collider.shape === "circle" + ? collider.z + : (collider.minZ + collider.maxZ) * 0.5 + } + + export function contains(collider: Collider, x: number, z: number): boolean { + if (collider.shape === "circle") { + const dx = x - collider.x + const dz = z - collider.z + return dx * dx + dz * dz <= collider.radius * collider.radius + } + return ( + x >= collider.minX && + x <= collider.maxX && + z >= collider.minZ && + z <= collider.maxZ + ) + } + + /** Push a horizontal player circle out of one collider. */ + export function pushOut( + collider: Collider, + position: Vec3, + radius: number, + ): void { + if (collider.shape === "circle") { + pushFromCircle(position, radius, collider) + return + } + pushFromBox(position, radius, collider) + } + + function pushFromBox(position: Vec3, radius: number, box: BoxCollider): void { + const cx = Math.max(box.minX, Math.min(box.maxX, position.x)) + const cz = Math.max(box.minZ, Math.min(box.maxZ, position.z)) + const dx = position.x - cx + const dz = position.z - cz + const distanceSquared = dx * dx + dz * dz + if (distanceSquared >= radius * radius) { + return + } + if (distanceSquared > 1e-6) { + const distance = Math.sqrt(distanceSquared) + const push = (radius - distance) / distance + position.x += dx * push + position.z += dz * push + return + } + const left = position.x - box.minX + const right = box.maxX - position.x + const near = position.z - box.minZ + const far = box.maxZ - position.z + const nearest = Math.min(left, right, near, far) + if (nearest === left) { + position.x = box.minX - radius + } else if (nearest === right) { + position.x = box.maxX + radius + } else if (nearest === near) { + position.z = box.minZ - radius + } else { + position.z = box.maxZ + radius + } + } + + function pushFromCircle( + position: Vec3, + radius: number, + circle: CircleCollider, + ): void { + const dx = position.x - circle.x + const dz = position.z - circle.z + const reach = radius + circle.radius + const distanceSquared = dx * dx + dz * dz + if (distanceSquared >= reach * reach || distanceSquared < 1e-6) { + return + } + const distance = Math.sqrt(distanceSquared) + const push = (reach - distance) / distance + position.x += dx * push + position.z += dz * push + } +} diff --git a/engine/world/CollisionWorld.ts b/engine/world/CollisionWorld.ts new file mode 100644 index 0000000..2f2e945 --- /dev/null +++ b/engine/world/CollisionWorld.ts @@ -0,0 +1,62 @@ +import type { Vec3 } from "../math/Vec3" +import { Collider, type Collider as ColliderShape } from "./Collider" +import type { Terrain } from "./Terrain" + +export type CollisionWorld = { + terrain: Terrain + staticColliders: ColliderShape[] + dynamicColliders: ColliderShape[] +} + +export namespace CollisionWorld { + export function create( + terrain: Terrain, + staticColliders: ColliderShape[], + ): CollisionWorld { + return { terrain, staticColliders, dynamicColliders: [] } + } + + export function pushOut( + world: CollisionWorld, + position: Vec3, + radius: number, + ): void { + pushFrom(world.staticColliders, position, radius) + pushFrom(world.dynamicColliders, position, radius) + } + + export function groundHeight(world: CollisionWorld, position: Vec3): number { + let ground = world.terrain.heightAt(position.x, position.z) + ground = standingHeight(world.staticColliders, position, ground) + return standingHeight(world.dynamicColliders, position, ground) + } + + function pushFrom( + colliders: ColliderShape[], + position: Vec3, + radius: number, + ): void { + for (const collider of colliders) { + if (position.y < collider.top - 0.01) { + Collider.pushOut(collider, position, radius) + } + } + } + + function standingHeight( + colliders: ColliderShape[], + position: Vec3, + initial: number, + ): number { + let ground = initial + for (const collider of colliders) { + if ( + collider.standable && + Collider.contains(collider, position.x, position.z) + ) { + ground = Math.max(ground, collider.top) + } + } + return ground + } +} diff --git a/engine/world/Level.ts b/engine/world/Level.ts new file mode 100644 index 0000000..e608940 --- /dev/null +++ b/engine/world/Level.ts @@ -0,0 +1,123 @@ +import { + Actor, + type Actor as RuntimeActor, +} from "../scene/Actor" +import { Collider, type Collider as CollisionShape } from "./Collider" +import { + CollisionWorld, + type CollisionWorld as Collision, +} from "./CollisionWorld" +import type { Terrain } from "./Terrain" +import { + RenderScene, + type Billboard, + type RenderInstance, + type RenderPrototype, + type RenderScene as Scene, +} from "../render/RenderScene" +import type { Chunk } from "../render/Chunk" +import type { DrawGroup } from "../render/Material" +import type { SkyConfig } from "../render/Sky" +import type { Mat4 } from "../math/Mat4" +import type { Vec3 } from "../math/Vec3" + +export type LevelDefinition = { + terrain: Terrain + actorWorld: World + actors: readonly RuntimeActor[] + staticColliders: CollisionShape[] + staticGroups: DrawGroup[] + chunks: Chunk[] + billboards: Billboard[] + sky: SkyConfig +} + +/** Live engine world. Behavior-bearing actors stay here on the main thread; only + * `render` is clone-safe and sent to workers. */ +export type Level = { + readonly terrain: Terrain + readonly actorWorld: World + readonly actors: readonly RuntimeActor[] + readonly collision: Collision + readonly render: Scene +} + +const prototypeIndexes = new WeakMap>() + +export namespace Level { + export function create(definition: LevelDefinition): Level { + const actors = Object.freeze([...definition.actors]) + const prototypes: RenderPrototype[] = [] + const prototypeIndex = new Map() + for (const actor of actors) { + if (!prototypeIndex.has(actor.definition)) { + prototypeIndex.set(actor.definition, prototypes.length) + prototypes.push(actor.prototype) + } + } + const level: Level = { + terrain: definition.terrain, + actorWorld: definition.actorWorld, + actors, + collision: CollisionWorld.create( + definition.terrain, + definition.staticColliders, + ), + render: Object.freeze({ + chunks: Object.freeze([...definition.chunks]), + staticGroups: Object.freeze([...definition.staticGroups]), + billboards: Object.freeze([...definition.billboards]), + prototypes: Object.freeze(prototypes), + maxInstances: actors.length, + sky: definition.sky, + }), + } + prototypeIndexes.set(level, prototypeIndex) + return level + } + + export function update(level: Level, dt: number): void { + for (const actor of level.actors) { + Actor.update(actor, dt, level.actorWorld) + } + } + + export function visibleInstances( + level: Level, + viewProjection: Mat4, + ): RenderInstance[] { + const prototypeIndex = prototypeIndexes.get(level) + if (prototypeIndex === undefined) { + throw new Error("level was not created by Level.create") + } + const instances: RenderInstance[] = [] + for (const actor of level.actors) { + const prototype = prototypeIndex.get(actor.definition) + if (prototype !== undefined) { + instances.push({ prototype, ...Actor.transform(actor) }) + } + } + return RenderScene.visibleInstances(level.render, instances, viewProjection) + } + + export function refreshActorColliders( + level: Level, + focus: Vec3, + range: number, + ): void { + const dynamic = level.collision.dynamicColliders + dynamic.length = 0 + const rangeSquared = range * range + for (const actor of level.actors) { + const collider = Actor.collider(actor, level.actorWorld) + if (collider === null) { + continue + } + const dx = Collider.centerX(collider) - focus.x + const dz = Collider.centerZ(collider) - focus.z + if (dx * dx + dz * dz <= rangeSquared) { + dynamic.push(collider) + } + } + } +} diff --git a/engine/world/Terrain.ts b/engine/world/Terrain.ts new file mode 100644 index 0000000..e015a0f --- /dev/null +++ b/engine/world/Terrain.ts @@ -0,0 +1,121 @@ +import { STRIDE, type Mesh } from "../scene/Mesh" + +/** A bounded ground surface. Implementations may be procedural, sampled, or + * loaded; callers only depend on world-space height sampling. */ +export type Terrain = { + minX: number + minZ: number + maxX: number + maxZ: number + heightAt: (x: number, z: number) => number +} + +/** Parameters for the built-in rolling terrain generator. Values describe the + * surface only; level-specific holes and materials belong to level data. */ +export type RollingTerrainConfig = { + inner: number + outer: number + blend: number + amplitude: number + frequency: number + peakHeight: number + peakFrequency: number + peakStart: number +} + +export namespace Terrain { + /** Built-in square world with a flat center, rolling hills, and edge ridges. */ + export function rolling(config: RollingTerrainConfig): Terrain { + return { + minX: -config.outer, + minZ: -config.outer, + maxX: config.outer, + maxZ: config.outer, + heightAt(x, z) { + const r = Math.max(Math.abs(x), Math.abs(z)) + if (r <= config.inner) { + return 0 + } + const rise = smoothstep(config.inner, config.inner + config.blend, r) + const hills = config.amplitude * bumps(x, z, config.frequency) + const k = Math.min( + 1, + (r - config.inner) / (config.outer - config.inner), + ) + const peaks = + config.peakHeight * + ridges(x, z, config.peakFrequency) * + smoothstep(config.peakStart, 1, k) + return rise * (hills + peaks) + }, + } + } + + export function height(terrain: Terrain, x: number, z: number): number { + return terrain.heightAt(x, z) + } + + /** Append one sampled heightfield patch. `include` is level policy evaluated at + * each quad center, allowing arbitrary holes without teaching terrain what + * occupies them. */ + export function patch( + terrain: Terrain, + mesh: Mesh, + x0: number, + z0: number, + x1: number, + z1: number, + cols: number, + rows: number, + uvScale: number, + include?: (x: number, z: number) => boolean, + ): void { + const base = mesh.verts.length / STRIDE + const dx = (x1 - x0) / cols + const dz = (z1 - z0) / rows + const rowLength = cols + 1 + for (let row = 0; row <= rows; row++) { + const z = z0 + row * dz + for (let col = 0; col <= cols; col++) { + const x = x0 + col * dx + mesh.verts.push(x, terrain.heightAt(x, z), z, x * uvScale, z * uvScale) + } + } + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const cx = x0 + (col + 0.5) * dx + const cz = z0 + (row + 0.5) * dz + if (include !== undefined && !include(cx, cz)) { + continue + } + const p = base + row * rowLength + col + mesh.indices.push( + p, + p + rowLength + 1, + p + 1, + p, + p + rowLength, + p + rowLength + 1, + ) + } + } + } + + function bumps(x: number, z: number, frequency: number): number { + const a = Math.sin(x * frequency) * Math.cos(z * frequency) + const b = Math.sin((x + z) * frequency * 0.5 + 1.7) * 0.5 + return (a + b + 1.5) / 3 + } + + function ridges(x: number, z: number, frequency: number): number { + const n = + Math.sin(x * frequency + 1.3) * Math.cos(z * frequency - 0.7) * 0.7 + + Math.sin((x + z) * frequency * 0.6 + 2.5) * 0.3 + return 1 - Math.abs(n) + } + + function smoothstep(a: number, b: number, x: number): number { + const t = Math.max(0, Math.min(1, (x - a) / (b - a || 1e-4))) + return t * t * (3 - 2 * t) + } +} diff --git a/game/Terrain.ts b/game/Terrain.ts deleted file mode 100644 index b38e6a9..0000000 --- a/game/Terrain.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { STRIDE, type Mesh } from "../engine/scene/Mesh" - -/** A procedural heightfield surrounding the room. It is the single source of - * ground height: the outdoor mesh is built from it and the player stands on the - * same `height` samples, so what you see and what you collide with agree. The - * center (out to `inner`) is a flat clearing where the room sits; from there the - * land rolls outward and ramps up into tall peaks at the far edge. Every field - * is a live knob -- edit them in the level to reshape the world. */ -export type Terrain = { - /** Half-extent of the flat central clearing (the room lives here); height 0. */ - inner: number - /** World half-extent. Peaks ramp up toward this outer rim. */ - outer: number - /** Ease-up distance just outside `inner`, so the clearing meets the hills with - * a slope instead of a wall. */ - blend: number - /** Rolling-hill height across the open ground. */ - amplitude: number - /** Rolling-hill frequency (low = broad hills over the big world). */ - frequency: number - /** Extra height of the mountains near the edge -- make this big for peaks. */ - peakHeight: number - /** Mountain frequency (low = few, massive ridges). */ - peakFrequency: number - /** Fraction of the way out (0..1) where the peaks begin rising. */ - peakStart: number -} - -export namespace Terrain { - /** Ground height at world (x, z). 0 inside the clearing, rolling hills beyond, - * ramping into peaks toward the edge. Uses a square (Chebyshev) radius so the - * clearing is a square that lines up with the square room. */ - export function height(t: Terrain, x: number, z: number): number { - const r = Math.max(Math.abs(x), Math.abs(z)) - if (r <= t.inner) { - return 0 - } - const rise = smoothstep(t.inner, t.inner + t.blend, r) - const hills = t.amplitude * bumps(x, z, t.frequency) - const k = Math.min(1, (r - t.inner) / (t.outer - t.inner)) - const peaks = t.peakHeight * ridges(x, z, t.peakFrequency) * smoothstep(t.peakStart, 1, k) - return rise * (hills + peaks) - } - - /** Append one ground patch: a `cols`x`rows` heightfield grid over the rectangle - * [x0,x1] x [z0,z1], each vertex lifted onto the heightfield. Quads whose - * center is inside the clearing are skipped (the room floor's hole). UVs use - * world position * `uvScale`, so neighboring patches tile seamlessly. Callers - * keep the spacing uniform and cell edges aligned, so shared edges weld with - * no cracks. Used to build the terrain per spatial chunk. */ - export function patch( - t: Terrain, - mesh: Mesh, - x0: number, - z0: number, - x1: number, - z1: number, - cols: number, - rows: number, - uvScale: number, - ): void { - const base = mesh.verts.length / STRIDE - const dx = (x1 - x0) / cols - const dz = (z1 - z0) / rows - const rowLen = cols + 1 - for (let i = 0; i <= rows; i++) { - const z = z0 + i * dz - for (let j = 0; j <= cols; j++) { - const x = x0 + j * dx - mesh.verts.push(x, height(t, x, z), z, x * uvScale, z * uvScale) - } - } - for (let i = 0; i < rows; i++) { - for (let j = 0; j < cols; j++) { - const cx = x0 + (j + 0.5) * dx - const cz = z0 + (i + 0.5) * dz - if (Math.max(Math.abs(cx), Math.abs(cz)) < t.inner) { - continue - } - const p = base + i * rowLen + j - // Wound so the surface faces up/out, matching the backface-cull sign. - mesh.indices.push(p, p + rowLen + 1, p + 1, p, p + rowLen, p + rowLen + 1) - } - } - } - - /** Rolling hills in 0..1, always non-negative so the ground never dips below - * the clearing. */ - function bumps(x: number, z: number, f: number): number { - const a = Math.sin(x * f) * Math.cos(z * f) - const b = Math.sin((x + z) * f * 0.5 + 1.7) * 0.5 - return (a + b + 1.5) / 3 - } - - /** Ridged noise in 0..1: crests where the field crosses zero give sharp - * mountain ridgelines rather than round blobs. */ - function ridges(x: number, z: number, f: number): number { - const n = Math.sin(x * f + 1.3) * Math.cos(z * f - 0.7) * 0.7 + Math.sin((x + z) * f * 0.6 + 2.5) * 0.3 - return 1 - Math.abs(n) - } - - function smoothstep(a: number, b: number, x: number): number { - const t = Math.max(0, Math.min(1, (x - a) / (b - a || 1e-4))) - return t * t * (3 - 2 * t) - } -} diff --git a/game/actors/Boulder.ts b/game/actors/Boulder.ts index 7d412f6..3c76819 100644 --- a/game/actors/Boulder.ts +++ b/game/actors/Boulder.ts @@ -1,5 +1,7 @@ import type { Vec3 } from "../../engine/math/Vec3" import { STRIDE, type Mesh } from "../../engine/scene/Mesh" +import type { Material } from "../../engine/render/Material" +import type { Prefab } from "../../engine/scene/Prefab" const TAU = Math.PI * 2 @@ -22,8 +24,35 @@ export type Boulder = { * field of boulders batches into a single draw call. */ export namespace Boulder { + export function create(material: Material): Prefab { + return { + position: (boulder) => boulder.position, + bakeNear: (boulder, batch) => build(boulder, batch.mesh(material)), + bakeFar: (boulder, batch) => + build(boulder, batch.mesh(material), "impostor"), + collider(boulder) { + if (boulder.radius <= 0.7) { + return null + } + return { + shape: "box", + minX: boulder.position.x - boulder.radius, + maxX: boulder.position.x + boulder.radius, + minZ: boulder.position.z - boulder.radius, + maxZ: boulder.position.z + boulder.radius, + top: boulder.position.y + boulder.radius * 0.7, + standable: false, + } + }, + } + } + /** `lod` "impostor" bakes a coarser rock (fewer facets) for far chunks. */ - export function build(boulder: Boulder, mesh: Mesh, lod: "full" | "impostor" = "full"): void { + export function build( + boulder: Boulder, + mesh: Mesh, + lod: "full" | "impostor" = "full", + ): void { const rand = rng(boulder.seed) const seg = lod === "impostor" ? 4 : 5 const rings = lod === "impostor" ? 2 : 4 @@ -67,7 +96,11 @@ export namespace Boulder { /** Per-vertex radial scale in ~0.72..1.14 for a chunky, angular surface. The * longitude seam (last column == first) and each pole row (one shared value) * match so the mesh stays closed. */ - function jitterGrid(seg: number, rings: number, rand: () => number): number[][] { + function jitterGrid( + seg: number, + rings: number, + rand: () => number, + ): number[][] { const grid: number[][] = [] for (let ir = 0; ir <= rings; ir++) { const pole = ir === 0 || ir === rings diff --git a/game/actors/Bush.ts b/game/actors/Bush.ts index c8fd73e..1c6b47d 100644 --- a/game/actors/Bush.ts +++ b/game/actors/Bush.ts @@ -1,5 +1,7 @@ import type { Vec3 } from "../../engine/math/Vec3" import { STRIDE, type Mesh } from "../../engine/scene/Mesh" +import type { Material } from "../../engine/render/Material" +import type { Prefab } from "../../engine/scene/Prefab" const TAU = Math.PI * 2 @@ -17,6 +19,13 @@ export type Bush = { * wound outward, so backface culling is safe. `build` appends into a shared * (leaf-textured) mesh. */ export namespace Bush { + export function create(material: Material): Prefab { + return { + position: (bush) => bush.position, + bakeNear: (bush, batch) => build(bush, batch.mesh(material)), + } + } + export function build(bush: Bush, mesh: Mesh): void { const rand = rng(bush.seed) // A handful of smaller overlapping lumps reads as a soft shrub; one big @@ -34,7 +43,14 @@ export namespace Bush { } /** A small lumpy low-poly sphere, wound outward (matches the oak canopy blob). */ - function blob(mesh: Mesh, cx: number, cy: number, cz: number, radius: number, rand: () => number): void { + function blob( + mesh: Mesh, + cx: number, + cy: number, + cz: number, + radius: number, + rand: () => number, + ): void { const seg = 6 const rings = 4 const start = mesh.verts.length / STRIDE diff --git a/game/actors/Flower.ts b/game/actors/Flower.ts index 6a13cc7..5bcf167 100644 --- a/game/actors/Flower.ts +++ b/game/actors/Flower.ts @@ -1,17 +1,19 @@ import type { Vec3 } from "../../engine/math/Vec3" import { Mesh } from "../../engine/scene/Mesh" +import type { Material } from "../../engine/render/Material" +import type { Prefab } from "../../engine/scene/Prefab" const TAU = Math.PI * 2 -/** Flower bloom color, indexing a region of the `flower` texture atlas. */ -export type FlowerColor = "white" | "red" | "yellow" +/** Concrete atlas style referenced directly by flower instances. */ +export type FlowerStyle = { bloomUv: [number, number] } /** A single small flower: a thin crossed-quad stem plus a shallow fan of petals. * Tiny, so it is drawn double-sided (no backface cull) and carries no collider. * `size` is roughly its height; `seed` jitters the petals. */ export type Flower = { position: Vec3 - color: FlowerColor + style: FlowerStyle size: number seed: number } @@ -23,16 +25,21 @@ export type Flower = { * or draw call. `build` appends into one shared flower mesh. */ export namespace Flower { - /** uv center of each bloom color's atlas region (tile units). */ - const BLOOM_UV: Record = { - white: [0.75, 0.25], - red: [0.25, 0.75], - yellow: [0.75, 0.75], - } + export const white: FlowerStyle = { bloomUv: [0.75, 0.25] } + export const red: FlowerStyle = { bloomUv: [0.25, 0.75] } + export const yellow: FlowerStyle = { bloomUv: [0.75, 0.75] } + /** uv center of the green stem region. */ const STEM_U = 0.25 const STEM_V = 0.25 + export function create(material: Material): Prefab { + return { + position: (flower) => flower.position, + bakeNear: (flower, batch) => build(flower, batch.mesh(material)), + } + } + export function build(flower: Flower, mesh: Mesh): void { const rand = rng(flower.seed) const p = flower.position @@ -43,14 +50,21 @@ export namespace Flower { stem(mesh, p.x, p.y, p.z, bloomY, w, 0) stem(mesh, p.x, p.y, p.z, bloomY, 0, w) // Bloom: a shallow fan of petals, center raised a touch so it domes. - const [bu, bv] = BLOOM_UV[flower.color] + const [bu, bv] = flower.style.bloomUv const rad = flower.size * 0.38 const center = Mesh.push(mesh, p.x, bloomY + rad * 0.3, p.z, bu, bv) const ring = center + 1 const petals = 5 for (let i = 0; i <= petals; i++) { const angle = (i / petals) * TAU + rand() * 0.4 - Mesh.push(mesh, p.x + Math.cos(angle) * rad, bloomY, p.z + Math.sin(angle) * rad, bu, bv) + Mesh.push( + mesh, + p.x + Math.cos(angle) * rad, + bloomY, + p.z + Math.sin(angle) * rad, + bu, + bv, + ) } for (let i = 0; i < petals; i++) { mesh.indices.push(center, ring + i, ring + i + 1) @@ -58,7 +72,15 @@ export namespace Flower { } /** A thin vertical quad from the ground to `y1`, width along (dx, dz). */ - function stem(mesh: Mesh, x: number, y0: number, z: number, y1: number, dx: number, dz: number): void { + function stem( + mesh: Mesh, + x: number, + y0: number, + z: number, + y1: number, + dx: number, + dz: number, + ): void { const a = Mesh.push(mesh, x - dx, y0, z - dz, STEM_U, STEM_V) const b = Mesh.push(mesh, x + dx, y0, z + dz, STEM_U, STEM_V) const c = Mesh.push(mesh, x + dx, y1, z + dz, STEM_U, STEM_V) diff --git a/game/actors/Mob.ts b/game/actors/Mob.ts index 29b3487..ff64bb5 100644 --- a/game/actors/Mob.ts +++ b/game/actors/Mob.ts @@ -1,27 +1,14 @@ -import type { Terrain } from "../Terrain" import type { Vec3 } from "../../engine/math/Vec3" -import type { Mesh } from "../../engine/scene/Mesh" -import type { Entity } from "../../engine/scene/Actor" -import { frog } from "./mobs/Frog" -import { bee } from "./mobs/Bee" -import { robin } from "./mobs/Robin" +import type { Actor } from "../../engine/scene/Actor" +import type { RenderTransform } from "../../engine/render/RenderScene" +import type { BoxCollider } from "../../engine/world/Collider" +import type { Terrain } from "../../engine/world/Terrain" -/** A roaming creature drawn as a moving low-poly mesh (unlike the static baked - * world). Each kind is an `Entity` definition (geometry + behavior + bounds) living - * in its own module under `mobs/`; this file just assembles them into a registry - * and exposes a thin per-kind dispatch. Adding a kind = add a `mobs/.ts` + - * one entry in `MOB_KINDS`/`DEFS`. - * - * A mob's geometry is a **canonical local-space mesh** built once per kind (front = - * +Z, frog/robin feet / bee body at the origin); the live `position`/`heading`/ - * `scale` are turned into a per-frame model matrix by the renderer. All wander - * state lives on the instance so `update` is a pure stepping function of the mob + - * dt (deterministic via the evolving `seed`), which keeps the sim on the main - * thread and cloneable-free. */ -export type MobKind = "frog" | "bee" | "robin" +/** Runtime actor using shared roaming-creature state. Concrete definitions are + * direct object references supplied by Frog, Bee, Robin, or future content. */ +export type Mob = Actor -export type Mob = { - kind: MobKind +export type MobState = { /** Leash anchor (where it was scattered); wandering is pulled back toward it. */ home: Vec3 /** Live feet-center (frog/robin) / body-center (bee), advanced each frame. */ @@ -39,46 +26,39 @@ export type Mob = { vy: number /** Countdown to the next decision (frog/robin: next hop; bee: next heading change). */ timer: number - /** Per-kind scratch clock: the bee's hover-bob phase; the robin's remaining + /** Behavior scratch clock: the bee's hover-bob phase; the robin's remaining * powered-flight cruise time (>0 while gliding between perches). */ phase: number /** Frog/robin: resting on the ground vs airborne (a hop or a flight). */ grounded: boolean } -/** Canonical kind order. **The index is the id packed into the mob SAB** (see - * renderer/worker), so this order must be identical in every context and must not - * change under existing kinds -- `mobs.test.ts` guards it. Append new kinds. */ -export const MOB_KINDS: MobKind[] = ["frog", "bee", "robin"] - -/** The per-kind `Entity` definitions, one module each. Imported (not cloned) into - * whatever context uses it, so it works the same on the main thread and in workers. */ -const DEFS: Record> = { frog, bee, robin } - export namespace Mob { - /** The definition for a kind (geometry, behavior, bounds). */ - export function def(kind: MobKind): Entity { - return DEFS[kind] + export function transform(state: MobState): RenderTransform { + return { + x: state.position.x, + y: state.position.y, + z: state.position.z, + heading: state.heading, + scale: state.scale, + } } - /** Advance one mob by `dt` seconds, sampling `terrain` for ground height. */ - export function update(mob: Mob, dt: number, terrain: Terrain): void { - DEFS[mob.kind].update(mob, dt, terrain) - } - - /** Append the canonical local-space mesh for `kind` into `mesh` (once per kind at - * load; every instance shares it, differing only by transform). */ - export function build(kind: MobKind, mesh: Mesh): void { - DEFS[kind].build(mesh) - } - - /** Local bounding radius (pre-scale), for building the per-frame cull AABB. */ - export function boundingRadius(kind: MobKind): number { - return DEFS[kind].boundingRadius - } - - /** Local body height (pre-scale), for the top of the stand-on collider. */ - export function bodyHeight(kind: MobKind): number { - return DEFS[kind].bodyHeight + export function collider( + state: MobState, + radius: number, + height: number, + standable: boolean, + ): BoxCollider { + const half = radius * state.scale * 0.7 + return { + shape: "box", + minX: state.position.x - half, + maxX: state.position.x + half, + minZ: state.position.z - half, + maxZ: state.position.z + half, + top: state.position.y + height * state.scale, + standable, + } } } diff --git a/game/actors/Tree.ts b/game/actors/Tree.ts index 9c6751b..0356872 100644 --- a/game/actors/Tree.ts +++ b/game/actors/Tree.ts @@ -1,54 +1,11 @@ import type { Vec3 } from "../../engine/math/Vec3" -import type { Mesh } from "../../engine/scene/Mesh" -import { oak } from "./trees/Oak" -import { spruce } from "./trees/Spruce" -import { birch } from "./trees/Birch" - -export type TreeKind = "oak" | "spruce" | "birch" /** One procedural tree instance. `growth` 0..1 runs sapling -> full grown: it scales * height and girth and adds canopy blobs / tiers. `seed` drives the per-tree random * wobble so a forest doesn't look cloned. */ export type Tree = { - kind: TreeKind /** Trunk base, sitting on the ground. */ position: Vec3 growth: number seed: number } - -/** Definition of a tree species: which chunk materials its trunk + foliage bake - * into, plus how to append its geometry. Each lives in its own `trees/.ts` - * module (silhouette carries the species read); this file just assembles them. - * `trunk`/`foliage` are chunk-material keys (see `level.ts` `ChunkMaterials`): - * oak/spruce use the brown `bark`, birch the white `birch`; foliage is the oak - * `leaf` or spruce `needle`. */ -export type TreeSpecies = { - kind: TreeKind - trunk: string - foliage: string - build: (tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor") => void -} - -/** All tree species (also the placement roll's palette). Trees are baked at load, - * not shipped per frame, so this order isn't an id contract like `MOB_KINDS` -- but - * keeping it lets placement + tests stay registry-driven. */ -export const TREE_KINDS: TreeKind[] = ["oak", "spruce", "birch"] - -/** The per-species definitions, one module each. Imported (not cloned) wherever - * used, so it works the same on the main thread and in workers. */ -const SPECIES: Record = { oak, spruce, birch } - -export namespace Tree { - /** The species definition for a kind (its trunk/foliage materials + geometry). */ - export function species(kind: TreeKind): TreeSpecies { - return SPECIES[kind] - } - - /** Append one tree into the caller-provided `trunk` + `foliage` meshes (which the - * caller selects from the species' `trunk`/`foliage` material keys). `lod` - * "impostor" bakes a much cheaper stand-in for far chunks; "full" is up close. */ - export function build(tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor" = "full"): void { - SPECIES[tree.kind].build(tree, trunk, foliage, lod) - } -} diff --git a/game/actors/mobs/Bee.ts b/game/actors/mobs/Bee.ts index 82d3e8d..9befcbe 100644 --- a/game/actors/mobs/Bee.ts +++ b/game/actors/mobs/Bee.ts @@ -1,15 +1,28 @@ -import { Terrain } from "../../Terrain" -import type { Mesh } from "../../../engine/scene/Mesh" -import type { Mob } from "../Mob" -import type { Entity } from "../../../engine/scene/Actor" +import { Terrain, type Terrain as Ground } from "../../../engine/world/Terrain" +import { Mesh, type Mesh as Geometry } from "../../../engine/scene/Mesh" +import type { Material } from "../../../engine/render/Material" +import type { ActorDefinition } from "../../../engine/scene/Actor" +import { Mob, type MobState } from "../Mob" import { ellipsoid, nextRand, ovoidZ, wanderHeading, wing } from "./mobkit" -export const bee: Entity = { - name: "bee", - build, - update, - boundingRadius: 0.5, - bodyHeight: 0.5, +export type Bee = ActorDefinition + +export namespace Bee { + export function create(material: Material): Bee { + const mesh = Mesh.create() + build(mesh) + return { + prototype: { + groups: [{ mesh, material }], + radius: 0.6, + minY: -0.5, + maxY: 1, + }, + update, + transform: Mob.transform, + collider: (state) => Mob.collider(state, 0.5, 0.5, false), + } + } } const LEASH = 6 @@ -20,7 +33,7 @@ const HOVER = 1.1 const BOB_AMP = 0.18 const BOB_FREQ = 3 -function build(mesh: Mesh): void { +function build(mesh: Geometry): void { // Fore-aft ovoid body striped along its length, a dark head at the front, two // pale wings. UVs: bee texture is stripe bands (left), head-dark (mid), wing-pale // (right); the body maps v along z so the stripes band across it. @@ -30,7 +43,7 @@ function build(mesh: Mesh): void { wing(mesh, -1, 0.83, 0.99, 0, 1) } -function update(mob: Mob, dt: number, terrain: Terrain): void { +function update(mob: MobState, dt: number, terrain: Ground): void { mob.phase += dt mob.timer -= dt if (mob.timer <= 0) { diff --git a/game/actors/mobs/Frog.ts b/game/actors/mobs/Frog.ts index a703fa8..4bdafd4 100644 --- a/game/actors/mobs/Frog.ts +++ b/game/actors/mobs/Frog.ts @@ -1,17 +1,30 @@ -import { Terrain } from "../../Terrain" -import type { Mesh } from "../../../engine/scene/Mesh" -import type { Mob } from "../Mob" -import type { Entity } from "../../../engine/scene/Actor" +import { Terrain, type Terrain as Ground } from "../../../engine/world/Terrain" +import { Mesh, type Mesh as Geometry } from "../../../engine/scene/Mesh" +import type { Material } from "../../../engine/render/Material" +import type { ActorDefinition } from "../../../engine/scene/Actor" +import { Mob, type MobState } from "../Mob" import { ellipsoid, nextRand, wanderHeading } from "./mobkit" // Everything about the frog: squat, ground-bound, sits then springs a ballistic hop. -export const frog: Entity = { - name: "frog", - build, - update, - boundingRadius: 0.7, - bodyHeight: 0.6, +export type Frog = ActorDefinition + +export namespace Frog { + export function create(material: Material): Frog { + const mesh = Mesh.create() + build(mesh) + return { + prototype: { + groups: [{ mesh, material }], + radius: 0.7, + minY: -0.7, + maxY: 1.3, + }, + update, + transform: Mob.transform, + collider: (state) => Mob.collider(state, 0.7, 0.6, state.grounded), + } + } } const LEASH = 5 @@ -21,7 +34,7 @@ const HOP_SPEED = 1.6 const HOP_IMPULSE = 3.2 const GRAVITY = 14 -function build(mesh: Mesh): void { +function build(mesh: Geometry): void { // Wide squat body, two eye bumps on the top-front, two hind haunches. UVs: // the frog texture is green skin on the left, a dark eye tone on the right. ellipsoid(mesh, 0, 0.26, 0, 0.5, 0.28, 0.52, 6, 4, 0, 0.68, 0, 1) @@ -31,7 +44,7 @@ function build(mesh: Mesh): void { ellipsoid(mesh, -0.3, 0.2, -0.26, 0.2, 0.2, 0.26, 4, 3, 0, 0.68, 0, 1) } -function update(mob: Mob, dt: number, terrain: Terrain): void { +function update(mob: MobState, dt: number, terrain: Ground): void { if (mob.grounded) { mob.timer -= dt mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z) diff --git a/game/actors/mobs/Robin.ts b/game/actors/mobs/Robin.ts index 3a01f60..7473620 100644 --- a/game/actors/mobs/Robin.ts +++ b/game/actors/mobs/Robin.ts @@ -1,18 +1,31 @@ -import { Terrain } from "../../Terrain" -import type { Mesh } from "../../../engine/scene/Mesh" -import type { Mob } from "../Mob" -import type { Entity } from "../../../engine/scene/Actor" +import { Terrain, type Terrain as Ground } from "../../../engine/world/Terrain" +import { Mesh, type Mesh as Geometry } from "../../../engine/scene/Mesh" +import type { Material } from "../../../engine/render/Material" +import type { ActorDefinition } from "../../../engine/scene/Actor" +import { Mob, type MobState } from "../Mob" import { ellipsoid, nextRand, wanderHeading } from "./mobkit" // Everything about the robin: round red-breasted bird that mostly hops like a frog // but now and then takes a short powered flight to a new perch. -export const robin: Entity = { - name: "robin", - build, - update, - boundingRadius: 0.45, - bodyHeight: 0.55, +export type Robin = ActorDefinition + +export namespace Robin { + export function create(material: Material): Robin { + const mesh = Mesh.create() + build(mesh) + return { + prototype: { + groups: [{ mesh, material }], + radius: 0.5, + minY: -0.5, + maxY: 1, + }, + update, + transform: Mob.transform, + collider: (state) => Mob.collider(state, 0.45, 0.55, state.grounded), + } + } } const LEASH = 6 @@ -27,7 +40,7 @@ const FLY_IMPULSE = 3.5 const CRUISE = 0.8 const GRAVITY = 14 -function build(mesh: Mesh): void { +function build(mesh: Geometry): void { // Round European robin: plump brown body, an orange-red breast bulging on the // front, a round brown head with two dark eyes + a small dark beak, short tail. // UVs: robin texture is brown (left), orange breast (mid), dark eye/beak (right). @@ -40,7 +53,7 @@ function build(mesh: Mesh): void { ellipsoid(mesh, 0, 0.26, -0.32, 0.09, 0.05, 0.16, 4, 2, 0, 0.38, 0, 1) // tail (brown) } -function update(mob: Mob, dt: number, terrain: Terrain): void { +function update(mob: MobState, dt: number, terrain: Ground): void { if (mob.grounded) { mob.timer -= dt mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z) diff --git a/game/actors/mobs/mobkit.ts b/game/actors/mobs/mobkit.ts index 3127ebe..630f940 100644 --- a/game/actors/mobs/mobkit.ts +++ b/game/actors/mobs/mobkit.ts @@ -1,10 +1,8 @@ -import type { Mob } from "../Mob" +import type { MobState } from "../Mob" import { STRIDE, type Mesh } from "../../../engine/scene/Mesh" -// Shared building blocks for the per-kind mob definitions (Frog/Bee/Robin): the -// faceted geometry primitives and the deterministic wander helpers. Kept in its own -// module (no runtime import of `Mob`, only its type) so the per-kind files and the -// `Mob` registry don't form an import cycle. +// Shared building blocks for concrete mob definitions: faceted geometry primitives +// and deterministic wander helpers. export const TAU = Math.PI * 2 @@ -13,7 +11,11 @@ export const TAU = Math.PI * 2 /** A new heading: free wander when inside the leash, else biased back toward home * so the mob never drifts off into the peaks (`jitter` = the random cone half-width * in radians layered on top of the homeward bearing). */ -export function wanderHeading(mob: Mob, leash: number, jitter: number): number { +export function wanderHeading( + mob: MobState, + leash: number, + jitter: number, +): number { const dx = mob.home.x - mob.position.x const dz = mob.home.z - mob.position.z if (dx * dx + dz * dz > leash * leash) { @@ -24,7 +26,7 @@ export function wanderHeading(mob: Mob, leash: number, jitter: number): number { /** mulberry32 step over the mob's own `seed` (mutated), so a mob's motion is * deterministic and needs no external RNG object to clone. */ -export function nextRand(mob: Mob): number { +export function nextRand(mob: MobState): number { const a = (mob.seed + 0x6D2B79F5) | 0 mob.seed = a let t = Math.imul(a ^ (a >>> 15), 1 | a) @@ -33,7 +35,7 @@ export function nextRand(mob: Mob): number { } // --- Geometry primitives -------------------------------------------------- -// Mobs are drawn double-sided (see renderScene), so winding is not load-bearing -- +// Mobs are drawn double-sided, so winding is not load-bearing -- // these only need to place faceted, flat-shaded surfaces. /** A UV-rected ellipsoid (pole on Y), faceted like the boulders. */ @@ -61,7 +63,13 @@ export function ellipsoid( for (let is = 0; is <= seg; is++) { const theta = (is / seg) * TAU const u = u0 + (u1 - u0) * (is / seg) - mesh.verts.push(cx + crv * Math.cos(theta) * rx, cy + cyv * ry, cz + crv * Math.sin(theta) * rz, u, v) + mesh.verts.push( + cx + crv * Math.cos(theta) * rx, + cy + cyv * ry, + cz + crv * Math.sin(theta) * rz, + u, + v, + ) } } quadGrid(mesh, start, seg, rings) @@ -97,13 +105,36 @@ export function ovoidZ( } /** One flat wing quad on `side` (+1 right / -1 left), swept up and out. */ -export function wing(mesh: Mesh, side: number, u0: number, u1: number, v0: number, v1: number): void { +export function wing( + mesh: Mesh, + side: number, + u0: number, + u1: number, + v0: number, + v1: number, +): void { const base = mesh.verts.length / STRIDE mesh.verts.push( - side * 0.06, 0.12, 0.14, u0, v0, - side * 0.42, 0.24, 0.1, u1, v0, - side * 0.42, 0.24, -0.12, u1, v1, - side * 0.06, 0.12, -0.1, u0, v1, + side * 0.06, + 0.12, + 0.14, + u0, + v0, + side * 0.42, + 0.24, + 0.1, + u1, + v0, + side * 0.42, + 0.24, + -0.12, + u1, + v1, + side * 0.06, + 0.12, + -0.1, + u0, + v1, ) mesh.indices.push(base, base + 1, base + 2, base, base + 2, base + 3) } diff --git a/game/actors/trees/Birch.ts b/game/actors/trees/Birch.ts index 00eeba3..01a8c97 100644 --- a/game/actors/trees/Birch.ts +++ b/game/actors/trees/Birch.ts @@ -1,15 +1,51 @@ import { Vec3 } from "../../../engine/math/Vec3" import type { Mesh } from "../../../engine/scene/Mesh" -import type { Tree, TreeSpecies } from "../Tree" +import type { Material } from "../../../engine/render/Material" +import type { Prefab } from "../../../engine/scene/Prefab" +import type { Tree } from "../Tree" import { blob, lerp, limb, TAU, rng } from "./treekit" // Silver birch: tall, slender, near-straight trunk under an airy, high, slightly // drooping canopy -- a lean silhouette between the broad oak and conical spruce. // Trunk = white birch bark, foliage = oak leaf (the white trunk carries the read). -export const birch: TreeSpecies = { kind: "birch", trunk: "birch", foliage: "leaf", build } +export type Birch = Prefab -function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void { +export namespace Birch { + export function create(trunk: Material, foliage: Material): Birch { + return { + position: (tree) => tree.position, + bakeNear: (tree, batch) => + build(tree, batch.mesh(trunk), batch.mesh(foliage), "full"), + bakeFar: (tree, batch) => + build(tree, batch.mesh(trunk), batch.mesh(foliage), "impostor"), + collider: treeCollider, + } + } + + function treeCollider(tree: Tree) { + if (tree.growth <= 0.35) { + return null + } + const radius = tree.growth * 0.2 + 0.15 + return { + shape: "box" as const, + minX: tree.position.x - radius, + maxX: tree.position.x + radius, + minZ: tree.position.z - radius, + maxZ: tree.position.z + radius, + top: tree.position.y + 3, + standable: false, + } + } +} + +function build( + tree: Tree, + trunk: Mesh, + leaves: Mesh, + lod: "full" | "impostor", +): void { const base = tree.position const g = tree.growth const rand = rng(tree.seed) @@ -18,11 +54,25 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): const canopyY = base.y + h * 0.75 const blobR = h * 0.22 if (lod === "impostor") { - limb(trunk, base, { x: base.x, y: base.y + h * 0.9, z: base.z }, rTrunk, rTrunk * 0.5, 3) + limb( + trunk, + base, + { x: base.x, y: base.y + h * 0.9, z: base.z }, + rTrunk, + rTrunk * 0.5, + 3, + ) blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.1, rand, 4, 2) return } - limb(trunk, base, { x: base.x, y: base.y + h * 0.88, z: base.z }, rTrunk, rTrunk * 0.35, 5) + limb( + trunk, + base, + { x: base.x, y: base.y + h * 0.88, z: base.z }, + rTrunk, + rTrunk * 0.35, + 5, + ) const spread = h * 0.22 // Sparse small blobs clustered high, biased downward so the crown droops. @@ -42,7 +92,11 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): const branches = 2 + Math.round(rand()) for (let i = 0; i < branches; i++) { const angle = rand() * TAU - const dir = Vec3.normalize({ x: Math.cos(angle), y: 0.6, z: Math.sin(angle) }) + const dir = Vec3.normalize({ + x: Math.cos(angle), + y: 0.6, + z: Math.sin(angle), + }) const start = { x: base.x, y: base.y + h * 0.7, z: base.z } const end = Vec3.add(start, Vec3.scale(dir, h * 0.22)) limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.15, 4) diff --git a/game/actors/trees/Oak.ts b/game/actors/trees/Oak.ts index a6e7888..30c37e9 100644 --- a/game/actors/trees/Oak.ts +++ b/game/actors/trees/Oak.ts @@ -1,14 +1,48 @@ import { Vec3 } from "../../../engine/math/Vec3" import type { Mesh } from "../../../engine/scene/Mesh" -import type { Tree, TreeSpecies } from "../Tree" +import type { Material } from "../../../engine/render/Material" +import type { Prefab } from "../../../engine/scene/Prefab" +import type { Tree } from "../Tree" import { blob, lerp, limb, TAU, rng } from "./treekit" // Oak: short tapered trunk, a couple of branches, a broad cluster of rounded canopy // blobs (bushy, wider than tall). Trunk = brown bark, foliage = oak leaf. -export const oak: TreeSpecies = { kind: "oak", trunk: "bark", foliage: "leaf", build } +export type Oak = Prefab -function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void { +export namespace Oak { + export function create(trunk: Material, foliage: Material): Oak { + return { + position: (tree) => tree.position, + bakeNear: (tree, batch) => + build(tree, batch.mesh(trunk), batch.mesh(foliage), "full"), + bakeFar: (tree, batch) => + build(tree, batch.mesh(trunk), batch.mesh(foliage), "impostor"), + collider(tree) { + if (tree.growth <= 0.35) { + return null + } + const radius = tree.growth * 0.3 + 0.15 + return { + shape: "box", + minX: tree.position.x - radius, + maxX: tree.position.x + radius, + minZ: tree.position.z - radius, + maxZ: tree.position.z + radius, + top: tree.position.y + 3, + standable: false, + } + }, + } + } +} + +function build( + tree: Tree, + trunk: Mesh, + leaves: Mesh, + lod: "full" | "impostor", +): void { const base = tree.position const g = tree.growth const rand = rng(tree.seed) @@ -19,7 +53,14 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): const blobR = h * 0.3 if (lod === "impostor") { // One low-poly blob on a stubby trunk -- reads as an oak at distance. - limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 3) + limb( + trunk, + base, + { x: base.x, y: forkY, z: base.z }, + rTrunk, + rTrunk * 0.6, + 3, + ) blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.15, rand, 4, 2) return } @@ -43,7 +84,11 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): const branches = 2 + Math.round(rand()) for (let i = 0; i < branches; i++) { const angle = rand() * TAU - const dir = Vec3.normalize({ x: Math.cos(angle), y: 1.2, z: Math.sin(angle) }) + const dir = Vec3.normalize({ + x: Math.cos(angle), + y: 1.2, + z: Math.sin(angle), + }) const start = { x: base.x, y: base.y + h * 0.42, z: base.z } const end = Vec3.add(start, Vec3.scale(dir, h * 0.3)) limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.2, 4) diff --git a/game/actors/trees/Spruce.ts b/game/actors/trees/Spruce.ts index e7a2286..b168f65 100644 --- a/game/actors/trees/Spruce.ts +++ b/game/actors/trees/Spruce.ts @@ -1,20 +1,63 @@ import type { Mesh } from "../../../engine/scene/Mesh" -import type { Tree, TreeSpecies } from "../Tree" +import type { Material } from "../../../engine/render/Material" +import type { Prefab } from "../../../engine/scene/Prefab" +import type { Tree } from "../Tree" import { cone, lerp, limb, rng } from "./treekit" // Spruce: tall thin trunk under stacked cones that narrow to a point (tiered, taller // than wide). Trunk = brown bark, foliage = spruce needle. -export const spruce: TreeSpecies = { kind: "spruce", trunk: "bark", foliage: "needle", build } +export type Spruce = Prefab -function build(tree: Tree, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void { +export namespace Spruce { + export function create(trunk: Material, foliage: Material): Spruce { + return { + position: (tree) => tree.position, + bakeNear: (tree, batch) => + build(tree, batch.mesh(trunk), batch.mesh(foliage), "full"), + bakeFar: (tree, batch) => + build(tree, batch.mesh(trunk), batch.mesh(foliage), "impostor"), + collider: treeCollider, + } + } + + function treeCollider(tree: Tree) { + if (tree.growth <= 0.35) { + return null + } + const radius = tree.growth * 0.2 + 0.15 + return { + shape: "box" as const, + minX: tree.position.x - radius, + maxX: tree.position.x + radius, + minZ: tree.position.z - radius, + maxZ: tree.position.z + radius, + top: tree.position.y + 3, + standable: false, + } + } +} + +function build( + tree: Tree, + trunk: Mesh, + needles: Mesh, + lod: "full" | "impostor", +): void { const base = tree.position const g = tree.growth const rand = rng(tree.seed) const h = lerp(0.6, 9, g) const rTrunk = lerp(0.03, 0.2, g) const impostor = lod === "impostor" - limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, impostor ? 3 : 5) + limb( + trunk, + base, + { x: base.x, y: base.y + h, z: base.z }, + rTrunk, + rTrunk * 0.25, + impostor ? 3 : 5, + ) // Stacked cones: widest low, shrinking to a point up top -> conical tiers. The // impostor keeps the first two tiers at low sides (same seed => aligned). diff --git a/game/actors/trees/treekit.ts b/game/actors/trees/treekit.ts index 27e2e09..dd9b833 100644 --- a/game/actors/trees/treekit.ts +++ b/game/actors/trees/treekit.ts @@ -1,9 +1,8 @@ import { Vec3 } from "../../../engine/math/Vec3" import { STRIDE, type Mesh } from "../../../engine/scene/Mesh" -// Shared faceted-geometry primitives + the per-tree RNG, used by the species -// modules (Oak/Spruce/Birch). Kept separate so a species and the `Tree` registry -// don't form an import cycle. +// Shared faceted-geometry primitives + the per-tree RNG used by concrete tree +// prefab modules. export const TAU = Math.PI * 2 diff --git a/game/level.ts b/game/level.ts index a4780af..8244a78 100644 --- a/game/level.ts +++ b/game/level.ts @@ -1,51 +1,37 @@ import { Color } from "../engine/render/Color" -import type { DrawGroup, Material } from "../engine/render/Material" +import type { Material } from "../engine/render/Material" import type { CloudLayer, SkyConfig } from "../engine/render/Sky" -import { STRIDE, type Mesh } from "../engine/scene/Mesh" +import { ChunkBuilder } from "../engine/render/ChunkBuilder" +import { Actor, type ActorDefinition } from "../engine/scene/Actor" +import { Mesh } from "../engine/scene/Mesh" +import { MeshBuilder } from "../engine/scene/MeshBuilder" +import { + Prefab, + type PlacedPrefab, + type Prefab as PrefabDefinition, +} from "../engine/scene/Prefab" +import type { BoxCollider, Collider } from "../engine/world/Collider" +import { Level, type Level as RuntimeLevel } from "../engine/world/Level" +import { + Terrain, + type RollingTerrainConfig, + type Terrain as Ground, +} from "../engine/world/Terrain" import { Boulder } from "./actors/Boulder" import { Bush } from "./actors/Bush" -import { Flower, type FlowerColor } from "./actors/Flower" -import type { Mob, MobKind } from "./actors/Mob" -import { Terrain } from "./Terrain" -import { Tree } from "./actors/Tree" +import { Flower, type FlowerStyle } from "./actors/Flower" +import type { Mob, MobState } from "./actors/Mob" +import type { Tree } from "./actors/Tree" +import { Bee } from "./actors/mobs/Bee" +import { Frog } from "./actors/mobs/Frog" +import { Robin } from "./actors/mobs/Robin" +import { Birch } from "./actors/trees/Birch" +import { Oak } from "./actors/trees/Oak" +import { Spruce } from "./actors/trees/Spruce" import type { Textures } from "./textures" -type Corner = [number, number, number] - -/** Axis-aligned solid. Blocks the player horizontally while their feet are - * below `top`; if `standable`, its `top` also counts as ground to land on. */ -export type Aabb = { - minX: number - maxX: number - minZ: number - maxZ: number - top: number - standable: boolean -} - -/** One spatial cell of the outdoor world: its terrain patch + the trees/boulders - * standing in it, baked into `DrawGroup`s (mesh + material), plus an axis-aligned - * bounding box (tight to the actual geometry, so overhanging canopies aren't - * clipped). The renderer frustum-tests the box and skips the whole cell when it - * is off-screen -- this is what keeps a big, dense world affordable. Empty cells - * are never created; empty groups are pruned at bake time. */ -export type Chunk = { - minX: number - minY: number - minZ: number - maxX: number - maxY: number - maxZ: number - /** Full-detail draw groups (grass + full trees/boulders), used up close. */ - near: DrawGroup[] - /** LOD draw groups (grass + cheap tree/boulder impostors, no bushes/flowers), - * used once the chunk is past `config.lodDistance` (see `chunkFar`). */ - far: DrawGroup[] -} - -/** The materials the chunk baker binds its meshes to -- one per ground/prop - * texture. Built once from the loaded `Textures`, shared across every chunk. */ -type ChunkMaterials = { +type Materials = { + floor: Material grass: Material bark: Material birch: Material @@ -53,50 +39,35 @@ type ChunkMaterials = { needle: Material rock: Material flower: Material + wall: Material + crate: Material + npc: Material + frog: Material + bee: Material + robin: Material } -/** A chunk-material key (also the tag props reference, e.g. a tree's `trunk`). */ -type MatKey = keyof ChunkMaterials +type WeightedTree = { + definition: PrefabDefinition + weight: number +} -/** The fixed order draw groups are emitted in (grass first, flowers -- double-sided - * -- last), so the per-chunk draw sequence is deterministic and matches the pre- - * registry order. Every material key must appear here. */ -const MAT_ORDER: MatKey[] = ["grass", "rock", "bark", "birch", "leaf", "needle", "flower"] - -/** The playground: a flat-floored room dropped into the center of a big open - * landscape. The room (floor/walls/crate) is small and always drawn; the - * outdoor world is split into `chunks` that are frustum-culled per frame. */ -export type Level = { - floor: Mesh - walls: Mesh - crate: Mesh - chunks: Chunk[] - colliders: Aabb[] - npcPosition: { x: number; y: number; z: number } - /** Roaming mobs -- simulated on the main thread each frame (see main.ts), not - * baked into the static culled chunks. */ - mobs: Mob[] - terrain: Terrain - sky: SkyConfig +type MobSpawn = { + definition: ActorDefinition + count: number + minScale: number + maxScale: number + grounded: boolean + phase: (random: () => number) => number } const ARENA = 12 const WALL_HEIGHT = 4 -/** How deep the perimeter walls are. Thick enough to read as solid walls (and to - * give the doorway real jambs); their outer faces sit flush with the room edge, - * so they eat into the interior, not the terrain. */ const WALL_THICKNESS = 1.5 const CRATE = { x: -2, z: -2, half: 1, height: 1 } -/** Z-bias lifting the stone floor above the terrain skirt that laps under the - * room edge (see `buildLevel`). Big enough to beat depth precision, too small - * to see. */ const FLOOR_LIFT = 0.02 -/** The world around the room: a flat clearing the size of the room (`inner`), - * rolling hills beyond, ramping into very high peaks at the `outer` rim ~20x - * the room across. Tune freely -- crank `peakHeight` for taller mountains, - * `outer` for a bigger world. */ -const TERRAIN: Terrain = { +const TERRAIN_CONFIG: RollingTerrainConfig = { inner: ARENA, outer: ARENA * 10, blend: 12, @@ -106,52 +77,30 @@ const TERRAIN: Terrain = { peakFrequency: 0.05, peakStart: 0.45, } +const TERRAIN = Terrain.rolling(TERRAIN_CONFIG) -/** Forest: how many trees to scatter on the grass, and the seed for their - * placement/kind/growth. Trees ring the room out to `TREE_REACH` of the world; - * each rolls oak-or-spruce and a growth 0..1 (sapling .. full grown). */ const TREE_COUNT = 50 const TREE_SEED = 0x5EED const TREE_REACH = 1 - -/** Boulders: how many to scatter, their seed, and how far out they reach - * (fraction of the world). Sizes range small pebble .. big boulder. */ const BOULDER_COUNT = 50 const BOULDER_SEED = 0xB0142 const BOULDER_REACH = 1 - -/** Bushes + flowers: ground detail, kept to the nearer band since they're small - * and fog/size hides them far out. Flowers roll white/red/yellow. */ const BUSH_COUNT = 50 const BUSH_SEED = 0xB554 const BUSH_REACH = 1 const FLOWER_COUNT = 50 const FLOWER_SEED = 0xF10E const FLOWER_REACH = 0.3 -const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"] - -/** Roaming mobs: how many frogs/bees to scatter, their seed, and how far out they - * reach (fraction of the world). Kept modest -- roaming meshes are drawn every - * frame (frustum-culled), not baked into the static chunks. */ +const FLOWER_STYLES: FlowerStyle[] = [Flower.white, Flower.red, Flower.yellow] const FROG_COUNT = 20 const BEE_COUNT = 20 const ROBIN_COUNT = 20 const MOB_SEED = 0x30B const MOB_REACH = 1 - -/** Spatial partition of the world for frustum culling: `CHUNK_GRID` x - * `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter - * (less drawn off-screen) but cost more per-cell tests + bounds; this is the - * granularity knob. `TERRAIN_SUBDIV` is the terrain quads per cell edge, so the - * world's terrain resolution is `CHUNK_GRID * TERRAIN_SUBDIV`. `GROUND_UV` sets - * texture tiles/unit. */ const CHUNK_GRID = 12 const TERRAIN_SUBDIV = 5 const GROUND_UV = 0.25 -/** The two cloud styles; swap which one the sky uses in `buildLevel`. - * `basicCumulus` is cheap flat puffs; `fancyCumulus` is the pricier - * heightfield-shaded, domain-warped version with faked volume. */ export const basicCumulus: CloudLayer = { kind: "basic", color: Color.rgb(248, 250, 255), @@ -172,33 +121,63 @@ export const fancyCumulus: CloudLayer = { relief: 7, } -export function buildLevel(textures: Textures): Level { - // Flat room floor, lifted a hair above the terrain's clearing (y 0). The - // outdoor grid's cells straddle the room boundary and lap under the floor's - // edges; this small z-bias keeps the flat stone floor winning the depth test - // there instead of z-fighting the grass. The step is invisible at the doorway. - const floor = mesh() - const fy = FLOOR_LIFT - quad(floor, [-ARENA, fy, -ARENA], [ARENA, fy, -ARENA], [ARENA, fy, ARENA], [-ARENA, fy, ARENA], 12, 12) +/** Concrete playground data assembled through engine-owned level mechanisms. */ +export function buildLevel(textures: Textures): RuntimeLevel { + const materials = createMaterials(textures) + const floor = Mesh.create() + MeshBuilder.quad( + floor, + [-ARENA, FLOOR_LIFT, -ARENA], + [ARENA, FLOOR_LIFT, -ARENA], + [ARENA, FLOOR_LIFT, ARENA], + [-ARENA, FLOOR_LIFT, ARENA], + 12, + 12, + ) - const walls = mesh() - const h = WALL_HEIGHT - const t = WALL_THICKNESS - // Three thick perimeter walls, outer faces flush with the room edge; the north - // (-Z) side is left open onto the world. No ceiling, so the sky shows above. - slab(walls, -ARENA, ARENA, ARENA - t, ARENA, 0, h, 0.5) // south (+Z) - slab(walls, ARENA - t, ARENA, -ARENA, ARENA - t, 0, h, 0.5) // east (+X) - slab(walls, -ARENA, -ARENA + t, -ARENA, ARENA - t, 0, h, 0.5) // west (-X) + const walls = Mesh.create() + const thickness = WALL_THICKNESS + MeshBuilder.slab( + walls, + -ARENA, + ARENA, + ARENA - thickness, + ARENA, + 0, + WALL_HEIGHT, + 0.5, + ) + MeshBuilder.slab( + walls, + ARENA - thickness, + ARENA, + -ARENA, + ARENA - thickness, + 0, + WALL_HEIGHT, + 0.5, + ) + MeshBuilder.slab( + walls, + -ARENA, + -ARENA + thickness, + -ARENA, + ARENA - thickness, + 0, + WALL_HEIGHT, + 0.5, + ) - // Crate on the flat room floor. - const crate = mesh() - box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height) + const crate = Mesh.create() + MeshBuilder.box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height) - const colliders: Aabb[] = [ - wall(-ARENA, ARENA, ARENA - t, ARENA), - wall(ARENA - t, ARENA, -ARENA, ARENA - t), - wall(-ARENA, -ARENA + t, -ARENA, ARENA - t), + const npcPosition = { x: 2, y: 0, z: -1 } + const staticColliders: Collider[] = [ + wall(-ARENA, ARENA, ARENA - thickness, ARENA), + wall(ARENA - thickness, ARENA, -ARENA, ARENA - thickness), + wall(-ARENA, -ARENA + thickness, -ARENA, ARENA - thickness), { + shape: "box", minX: CRATE.x - CRATE.half, maxX: CRATE.x + CRATE.half, minZ: CRATE.z - CRATE.half, @@ -206,8 +185,94 @@ export function buildLevel(textures: Textures): Level { top: CRATE.height, standable: true, }, + { + shape: "circle", + x: npcPosition.x, + z: npcPosition.z, + radius: 0.5, + top: Infinity, + standable: false, + }, ] + const treeDefinitions: WeightedTree[] = [ + { definition: Oak.create(materials.bark, materials.leaf), weight: 0.4 }, + { + definition: Spruce.create(materials.bark, materials.needle), + weight: 0.32, + }, + { definition: Birch.create(materials.birch, materials.leaf), weight: 0.28 }, + ] + const props = [ + ...placeTrees(treeDefinitions), + ...placeBoulders(Boulder.create(materials.rock)), + ...placeBushes(Bush.create(materials.leaf)), + ...placeFlowers(Flower.create(materials.flower)), + ] + for (const prop of props) { + if (prop.collider !== null) { + staticColliders.push(prop.collider) + } + } + + const chunks = ChunkBuilder.build( + { + minX: TERRAIN.minX, + minZ: TERRAIN.minZ, + maxX: TERRAIN.maxX, + maxZ: TERRAIN.maxZ, + columns: CHUNK_GRID, + rows: CHUNK_GRID, + bakeCell(near, far, cell) { + const ground = near.mesh(materials.grass) + far.use(materials.grass, ground) + Terrain.patch( + TERRAIN, + ground, + cell.x0, + cell.z0, + cell.x1, + cell.z1, + TERRAIN_SUBDIV, + TERRAIN_SUBDIV, + GROUND_UV, + (x, z) => Math.max(Math.abs(x), Math.abs(z)) >= ARENA, + ) + }, + }, + props, + ) + + const frog = Frog.create(materials.frog) + const bee = Bee.create(materials.bee) + const robin = Robin.create(materials.robin) + const actors = placeMobs([ + { + definition: frog, + count: FROG_COUNT, + minScale: 0.5, + maxScale: 0.85, + grounded: true, + phase: () => 0, + }, + { + definition: bee, + count: BEE_COUNT, + minScale: 0.5, + maxScale: 0.8, + grounded: false, + phase: (random) => random() * 10, + }, + { + definition: robin, + count: ROBIN_COUNT, + minScale: 0.4, + maxScale: 0.65, + grounded: true, + phase: () => 0, + }, + ]) + const sky: SkyConfig = { zenith: Color.rgb(58, 108, 196), horizon: Color.rgb(178, 198, 226), @@ -218,12 +283,31 @@ export function buildLevel(textures: Textures): Level { skybox: { texture: textures.skybox }, } - const npcPosition = { x: 2, y: 0, z: -1 } + return Level.create({ + terrain: TERRAIN, + actorWorld: TERRAIN, + actors, + staticColliders, + staticGroups: [ + { mesh: floor, material: materials.floor }, + { mesh: walls, material: materials.wall }, + { mesh: crate, material: materials.crate }, + ], + chunks, + billboards: [ + { + position: npcPosition, + size: { x: 1.1, y: 1.5 }, + material: materials.npc, + }, + ], + sky, + }) +} - // The ground/prop materials the chunk baker draws with (grass + trees + rocks + - // flowers). Solid surfaces backface-cull; flowers are double-sided. Shared by - // every chunk, so cloning to a worker dedups them. - const materials: ChunkMaterials = { +function createMaterials(textures: Textures): Materials { + return { + floor: { texture: textures.floor, cull: false }, grass: { texture: textures.grass, cull: true }, bark: { texture: textures.bark, cull: true }, birch: { texture: textures.birch, cull: true }, @@ -231,319 +315,219 @@ export function buildLevel(textures: Textures): Level { needle: { texture: textures.needle, cull: true }, rock: { texture: textures.rock, cull: true }, flower: { texture: textures.flower, cull: false }, + wall: { texture: textures.wall, cull: false }, + crate: { texture: textures.crate, cull: false }, + npc: { texture: textures.npc, cull: false }, + frog: { texture: textures.frog, cull: false }, + bee: { texture: textures.bee, cull: false }, + robin: { texture: textures.robin, cull: false }, } - - // Place the props (also pushes their colliders), then bake everything into - // frustum-cullable spatial chunks. - const trees = placeTrees(colliders) - const boulders = placeBoulders(colliders) - const bushes = placeBushes() - const flowers = placeFlowers() - const chunks = buildChunks(materials, trees, boulders, bushes, flowers) - const mobs = placeMobs() - - return { floor, walls, crate, chunks, colliders, npcPosition, mobs, terrain: TERRAIN, sky } } -/** Bake the terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` set of spatial - * chunks. Each prop lands in the cell holding its base; the cell's bounds are - * grown to the real geometry so overhanging canopies never get culled early. - * Bushes share the oak leaf mesh; flowers get their own (double-sided) mesh. */ -function buildChunks(m: ChunkMaterials, trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers: Flower[]): Chunk[] { - const cell = (TERRAIN.outer * 2) / CHUNK_GRID - const chunks: Chunk[] = [] - for (let ci = 0; ci < CHUNK_GRID; ci++) { - const x0 = -TERRAIN.outer + ci * cell - const x1 = x0 + cell - for (let cj = 0; cj < CHUNK_GRID; cj++) { - const z0 = -TERRAIN.outer + cj * cell - const z1 = z0 + cell - // Accumulate geometry into one mesh per material key, for the near (full) and - // far (impostor) LOD sets. Props declare which material(s) they write, so the - // baker never names a texture -- adding a species/material touches no code here. - const near = new Map() - const far = new Map() - const grass = matMesh(near, "grass") - far.set("grass", grass) // the ground is drawn in both LOD sets - Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV) - for (const tree of trees) { - if (inCell(tree.position, x0, z0, x1, z1)) { - const s = Tree.species(tree.kind) - Tree.build(tree, matMesh(near, s.trunk), matMesh(near, s.foliage)) - Tree.build(tree, matMesh(far, s.trunk), matMesh(far, s.foliage), "impostor") - } - } - for (const boulder of boulders) { - if (inCell(boulder.position, x0, z0, x1, z1)) { - Boulder.build(boulder, matMesh(near, "rock")) - Boulder.build(boulder, matMesh(far, "rock"), "impostor") - } - } - // Bushes fold into the near leaf mesh; they just drop out past lodDistance. - for (const bush of bushes) { - if (inCell(bush.position, x0, z0, x1, z1)) { - Bush.build(bush, matMesh(near, "leaf")) - } - } - for (const flower of flowers) { - if (inCell(flower.position, x0, z0, x1, z1)) { - Flower.build(flower, matMesh(near, "flower")) - } - } - const b = bounds([...near.values()]) - if (b === null) { - continue - } - chunks.push({ ...b, near: toGroups(near, m), far: toGroups(far, m) }) - } - } - return chunks -} - -function inCell(p: { x: number; z: number }, x0: number, z0: number, x1: number, z1: number): boolean { - return p.x >= x0 && p.x < x1 && p.z >= z0 && p.z < z1 -} - -/** Lazily get (creating on first use) the accumulation mesh for a material key in a - * chunk's near/far map. Props write into these by key, so the baker stays generic. */ -function matMesh(map: Map, key: string): Mesh { - let m = map.get(key) - if (m === undefined) { - m = mesh() - map.set(key, m) - } - return m -} - -/** Turn a chunk's per-material meshes into a draw-group list, in a fixed material - * order (so the draw sequence is deterministic across bakes) and dropping any that - * ended up empty (a cell rarely holds every prop kind). */ -function toGroups(map: Map, materials: ChunkMaterials): DrawGroup[] { - const out: DrawGroup[] = [] - for (const key of MAT_ORDER) { - const m = map.get(key) - if (m !== undefined && m.indices.length > 0) { - out.push({ mesh: m, material: materials[key] }) - } - } - return out -} - -/** Tight AABB over several meshes' vertices, or null if they are all empty. */ -function bounds(meshes: Mesh[]): Pick | null { - let minX = Infinity - let minY = Infinity - let minZ = Infinity - let maxX = -Infinity - let maxY = -Infinity - let maxZ = -Infinity - for (const m of meshes) { - const verts = m.verts - for (let i = 0; i < verts.length; i += STRIDE) { - const x = verts[i] - const y = verts[i + 1] - const z = verts[i + 2] - minX = Math.min(minX, x) - minY = Math.min(minY, y) - minZ = Math.min(minZ, z) - maxX = Math.max(maxX, x) - maxY = Math.max(maxY, y) - maxZ = Math.max(maxZ, z) - } - } - return maxX < minX ? null : { minX, minY, minZ, maxX, maxY, maxZ } -} - -/** Place `TREE_COUNT` trees around the room on walkable grass: each sits on the - * terrain, rolls oak/spruce and a growth stage, and (once past sapling size) - * drops a trunk collider so you can't walk through it. */ -function placeTrees(colliders: Aabb[]): Tree[] { - const rand = mulberry(TREE_SEED) - const maxDist = TERRAIN.outer * TREE_REACH - const trees: Tree[] = [] - for (let guard = 0; trees.length < TREE_COUNT && guard < TREE_COUNT * 20; guard++) { - const angle = rand() * Math.PI * 2 - const dist = ARENA + 5 + rand() * (maxDist - ARENA - 5) - const x = Math.cos(angle) * dist - const z = Math.sin(angle) * dist - // Stay out of the room clearing and its flat rim. - if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 3) { +function placeTrees(definitions: WeightedTree[]): PlacedPrefab[] { + const random = mulberry(TREE_SEED) + const maxDistance = TERRAIN_CONFIG.outer * TREE_REACH + const trees: PlacedPrefab[] = [] + for ( + let guard = 0; + trees.length < TREE_COUNT && guard < TREE_COUNT * 20; + guard++ + ) { + const point = scatterPoint(random, maxDistance, 5, 3) + if (point === null) { continue } - const roll = rand() - const kind = roll < 0.4 ? "oak" : roll < 0.72 ? "spruce" : "birch" - const growth = 0.08 + rand() * 0.92 - const position = { x, y: Terrain.height(TERRAIN, x, z), z } - trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 }) - // Saplings are passable; grown trunks block. Square footprint, non-standable. - if (growth > 0.35) { - const r = growth * (kind === "oak" ? 0.3 : 0.2) + 0.15 - colliders.push({ minX: x - r, maxX: x + r, minZ: z - r, maxZ: z + r, top: position.y + 3, standable: false }) + const definition = weightedTree(definitions, random()) + const tree: Tree = { + position: { + x: point.x, + y: TERRAIN.heightAt(point.x, point.z), + z: point.z, + }, + growth: 0.08 + random() * 0.92, + seed: (random() * 0xFFFFFFFF) | 0, } + trees.push(Prefab.place(definition, tree)) } return trees } -/** Scatter `BOULDER_COUNT` boulders across the terrain, sizes biased toward - * small. Each sits on the ground; big ones drop a blocking collider so you - * can't walk through them (little rocks stay passable). */ -function placeBoulders(colliders: Aabb[]): Boulder[] { - const rand = mulberry(BOULDER_SEED) - const maxDist = TERRAIN.outer * BOULDER_REACH - const boulders: Boulder[] = [] - for (let guard = 0; boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20; guard++) { - const angle = rand() * Math.PI * 2 - const dist = ARENA + 4 + rand() * (maxDist - ARENA - 4) - const x = Math.cos(angle) * dist - const z = Math.sin(angle) * dist - if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) { +function placeBoulders(definition: PrefabDefinition): PlacedPrefab[] { + const random = mulberry(BOULDER_SEED) + const maxDistance = TERRAIN_CONFIG.outer * BOULDER_REACH + const boulders: PlacedPrefab[] = [] + for ( + let guard = 0; + boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20; + guard++ + ) { + const point = scatterPoint(random, maxDistance, 4, 2) + if (point === null) { continue } - // Square the roll so most rocks are small, a few are big. - const radius = 0.35 + rand() * rand() * 2.2 - const position = { x, y: Terrain.height(TERRAIN, x, z), z } - boulders.push({ position, radius, seed: (rand() * 0xFFFFFFFF) | 0 }) - if (radius > 0.7) { - colliders.push({ minX: x - radius, maxX: x + radius, minZ: z - radius, maxZ: z + radius, top: position.y + radius * 0.7, standable: false }) - } + boulders.push( + Prefab.place(definition, { + position: { + x: point.x, + y: TERRAIN.heightAt(point.x, point.z), + z: point.z, + }, + radius: 0.35 + random() * random() * 2.2, + seed: (random() * 0xFFFFFFFF) | 0, + }), + ) } return boulders } -/** Scatter bushes on the grass near the play area (no colliders -- walk through). */ -function placeBushes(): Bush[] { - const rand = mulberry(BUSH_SEED) - const maxDist = TERRAIN.outer * BUSH_REACH - const bushes: Bush[] = [] - for (let guard = 0; bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20; guard++) { - const angle = rand() * Math.PI * 2 - const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3) - const x = Math.cos(angle) * dist - const z = Math.sin(angle) * dist - if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) { +function placeBushes(definition: PrefabDefinition): PlacedPrefab[] { + const random = mulberry(BUSH_SEED) + const maxDistance = TERRAIN_CONFIG.outer * BUSH_REACH + const bushes: PlacedPrefab[] = [] + for ( + let guard = 0; + bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20; + guard++ + ) { + const point = scatterPoint(random, maxDistance, 3, 2) + if (point === null) { continue } - bushes.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, size: 0.8 + rand() * 1, seed: (rand() * 0xFFFFFFFF) | 0 }) + bushes.push( + Prefab.place(definition, { + position: { + x: point.x, + y: TERRAIN.heightAt(point.x, point.z), + z: point.z, + }, + size: 0.8 + random(), + seed: (random() * 0xFFFFFFFF) | 0, + }), + ) } return bushes } -/** Scatter small flowers on the grass near the play area, colors rolled. */ -function placeFlowers(): Flower[] { - const rand = mulberry(FLOWER_SEED) - const maxDist = TERRAIN.outer * FLOWER_REACH - const flowers: Flower[] = [] - for (let guard = 0; flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20; guard++) { - const angle = rand() * Math.PI * 2 - const dist = ARENA + 2 + rand() * (maxDist - ARENA - 2) - const x = Math.cos(angle) * dist - const z = Math.sin(angle) * dist - if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 1) { +function placeFlowers(definition: PrefabDefinition): PlacedPrefab[] { + const random = mulberry(FLOWER_SEED) + const maxDistance = TERRAIN_CONFIG.outer * FLOWER_REACH + const flowers: PlacedPrefab[] = [] + for ( + let guard = 0; + flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20; + guard++ + ) { + const point = scatterPoint(random, maxDistance, 2, 1) + if (point === null) { continue } - const color = FLOWER_COLORS[(rand() * FLOWER_COLORS.length) | 0] - flowers.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, color, size: 0.28 + rand() * 0.22, seed: (rand() * 0xFFFFFFFF) | 0 }) + flowers.push( + Prefab.place(definition, { + position: { + x: point.x, + y: TERRAIN.heightAt(point.x, point.z), + z: point.z, + }, + style: FLOWER_STYLES[(random() * FLOWER_STYLES.length) | 0], + size: 0.28 + random() * 0.22, + seed: (random() * 0xFFFFFFFF) | 0, + }), + ) } return flowers } -/** Scatter frogs, bees + robins across the grass (like the boulders), each at its - * home anchor with a random heading and size. No colliders here -- mobs move, so - * their block/stand-on AABBs are rebuilt per frame in `main`. */ -function placeMobs(): Mob[] { - const rand = mulberry(MOB_SEED) - const maxDist = TERRAIN.outer * MOB_REACH +function placeMobs(spawns: MobSpawn[]): Mob[] { + const random = mulberry(MOB_SEED) + const maxDistance = TERRAIN_CONFIG.outer * MOB_REACH const mobs: Mob[] = [] - const total = FROG_COUNT + BEE_COUNT + ROBIN_COUNT - for (let guard = 0; mobs.length < total && guard < total * 20; guard++) { - const angle = rand() * Math.PI * 2 - const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3) - const x = Math.cos(angle) * dist - const z = Math.sin(angle) * dist - if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) { - continue + for (const spawn of spawns) { + let placed = 0 + for ( + let guard = 0; + placed < spawn.count && guard < spawn.count * 20; + guard++ + ) { + const point = scatterPoint(random, maxDistance, 3, 2) + if (point === null) { + continue + } + const y = TERRAIN.heightAt(point.x, point.z) + const scale = spawn.minScale + random() * (spawn.maxScale - spawn.minScale) + const heading = random() * Math.PI * 2 + const state: MobState = { + home: { x: point.x, y, z: point.z }, + position: { x: point.x, y, z: point.z }, + heading, + scale, + seed: (random() * 0xFFFFFFFF) | 0, + vx: 0, + vz: 0, + vy: 0, + timer: random() * 1.5, + phase: spawn.phase(random), + grounded: spawn.grounded, + } + mobs.push(Actor.create(spawn.definition, state)) + placed++ } - const n = mobs.length - const kind: MobKind = n < FROG_COUNT ? "frog" : n < FROG_COUNT + BEE_COUNT ? "bee" : "robin" - const y = Terrain.height(TERRAIN, x, z) - const scale = kind === "frog" ? 0.5 + rand() * 0.35 : kind === "robin" ? 0.4 + rand() * 0.25 : 0.5 + rand() * 0.3 - mobs.push({ - kind, - home: { x, y, z }, - position: { x, y, z }, - heading: rand() * Math.PI * 2, - scale, - seed: (rand() * 0xFFFFFFFF) | 0, - vx: 0, - vz: 0, - vy: 0, - timer: rand() * 1.5, - // Bees hover (never grounded) and use phase for the bob; frogs/robins start - // resting on the ground. - phase: kind === "bee" ? rand() * 10 : 0, - grounded: kind !== "bee", - }) } return mobs } -/** Deterministic 0..1 generator (mulberry32) for tree placement. */ -function mulberry(seed: number): () => number { - let a = seed >>> 0 - return () => { - a = (a + 0x6D2B79F5) | 0 - let t = Math.imul(a ^ (a >>> 15), 1 | a) - t ^= t + Math.imul(t ^ (t >>> 7), 61 | t) - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 +function scatterPoint( + random: () => number, + maxDistance: number, + clearance: number, + flatMargin: number, +): { x: number; z: number } | null { + const angle = random() * Math.PI * 2 + const distance = + ARENA + clearance + random() * (maxDistance - ARENA - clearance) + const x = Math.cos(angle) * distance + const z = Math.sin(angle) * distance + return Math.max(Math.abs(x), Math.abs(z)) < TERRAIN_CONFIG.inner + flatMargin + ? null + : { x, z } +} + +function weightedTree( + definitions: WeightedTree[], + roll: number, +): PrefabDefinition { + let cumulative = 0 + for (const entry of definitions) { + cumulative += entry.weight + if (roll < cumulative) { + return entry.definition + } + } + return definitions[definitions.length - 1].definition +} + +function wall( + minX: number, + maxX: number, + minZ: number, + maxZ: number, +): BoxCollider { + return { + shape: "box", + minX, + maxX, + minZ, + maxZ, + top: WALL_HEIGHT, + standable: true, } } -function mesh(): Mesh { - return { verts: [], indices: [] } -} - -/** A perimeter wall collider: blocks from the sides, and `standable` so you can - * jump up and land on its top (given enough JUMP_SPEED to clear WALL_HEIGHT). */ -function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb { - return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: true } -} - -/** One flat quad (two tris). Corners run a (uv 0,0) -> b (us,0) -> c (us,vs) -> - * d (0,vs); `us`/`vs` set how many texture tiles span it. No subdivision is - * needed -- texturing is perspective-correct, so a single quad looks right at - * any size. */ -function quad(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number): void { - const base = m.verts.length / STRIDE - m.verts.push(a[0], a[1], a[2], 0, 0, b[0], b[1], b[2], us, 0, c[0], c[1], c[2], us, vs, d[0], d[1], d[2], 0, vs) - m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3) -} - -/** An axis-aligned box from (x0,z0)-(x1,z1), y0..y1: four sides + top, no bottom - * (never seen from below). `tpu` = texture tiles per world unit, so every face - * tiles at the same density whatever its size. Used for the thick walls. */ -function slab(m: Mesh, x0: number, x1: number, z0: number, z1: number, y0: number, y1: number, tpu: number): void { - const dx = (x1 - x0) * tpu - const dz = (z1 - z0) * tpu - const dy = (y1 - y0) * tpu - quad(m, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], dx, dz) - quad(m, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], dx, dy) - quad(m, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], dx, dy) - quad(m, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], dz, dy) - quad(m, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], dz, dy) -} - -/** A box centered at (cx, cz), rising `height` units from `base`: top face plus - * four sides, one uv tile per face. No bottom (never seen). */ -function box(m: Mesh, cx: number, cz: number, half: number, base: number, height: number): void { - const x0 = cx - half - const x1 = cx + half - const z0 = cz - half - const z1 = cz + half - const y0 = base - const y1 = base + height - quad(m, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], 1, 1) - quad(m, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], 1, 1) - quad(m, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], 1, 1) - quad(m, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], 1, 1) - quad(m, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], 1, 1) +function mulberry(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6D2B79F5) | 0 + let value = Math.imul(state ^ (state >>> 15), 1 | state) + value ^= value + Math.imul(value ^ (value >>> 7), 61 | value) + return ((value ^ (value >>> 14)) >>> 0) / 4294967296 + } } diff --git a/game/player.ts b/game/player.ts index f45d376..6fba438 100644 --- a/game/player.ts +++ b/game/player.ts @@ -1,169 +1,32 @@ -import { Terrain } from "./Terrain" -import type { Vec3 } from "../engine/math/Vec3" -import type { Aabb, Level } from "./level" +import type { + Character, + CharacterConfig, +} from "../engine/world/CharacterController" -/** The player as a vertical cylinder. `position` is at the feet; the camera - * eye sits EYE_HEIGHT above it. */ -export type Player = { - position: Vec3 - yaw: number +export type Player = Character & { pitch: number - velocityY: number - onGround: boolean } -export const EYE_HEIGHT = 1.6 -const RADIUS = 0.35 -const SPEED = 6 -/** Speed multiplier while a Run key (Shift) is held. Tweak to taste; set high to - * blast across the big terrain -- move+collision is substepped, so walls stay - * solid even at big multipliers. */ -const RUN_MULTIPLIER = 2 -const GRAVITY = 22 -const JUMP_SPEED = 14 -const NPC_RADIUS = 0.5 - +/** Concrete player tuning. Movement and collision behavior live in engine. */ export namespace Player { - /** Advance the player one frame: jump, horizontal move + collision, gravity. */ - export function update(player: Player, keys: Set, dt: number, level: Level): void { - if (keys.has("Space") && player.onGround) { - player.velocityY = JUMP_SPEED - player.onGround = false - } - // Move + collide in small substeps: collision is discrete (move, then push - // out), so a single big running step could otherwise skip clean through a - // wall. Substepping keeps each advance short enough to always hit it. - const steps = moveSubsteps(keys, dt) - for (let i = 0; i < steps; i++) { - moveHorizontal(player, keys, dt / steps) - collide(player, level) - } - fall(player, dt, level) + export const actorCollisionRange = 3 + + export const config: CharacterConfig = { + radius: 0.35, + speed: 6, + runMultiplier: 2, + gravity: 22, + jumpSpeed: 14, + eyeHeight: 1.6, } - /** Run-speed factor for the frame: RUN_MULTIPLIER while Shift is held, else 1. */ - function runFactor(keys: Set): number { - return keys.has("ShiftLeft") || keys.has("ShiftRight") ? RUN_MULTIPLIER : 1 - } - - /** Number of move+collide substeps so each advances at most ~RADIUS, keeping - * the player from tunneling walls however fast they run. */ - function moveSubsteps(keys: Set, dt: number): number { - const perFrame = SPEED * runFactor(keys) * dt * Math.SQRT2 - return Math.max(1, Math.ceil(perFrame / RADIUS)) - } - - function moveHorizontal(player: Player, keys: Set, dt: number): void { - const speed = SPEED * runFactor(keys) * dt - const fx = Math.sin(player.yaw) - const fz = -Math.cos(player.yaw) - const rx = Math.cos(player.yaw) - const rz = Math.sin(player.yaw) - const p = player.position - if (keys.has("KeyW")) { - p.x += fx * speed - p.z += fz * speed + export function create(): Player { + return { + position: { x: 0, y: 0, z: 8 }, + yaw: 0, + pitch: 0, + velocityY: 0, + onGround: true, } - if (keys.has("KeyS")) { - p.x -= fx * speed - p.z -= fz * speed - } - if (keys.has("KeyD")) { - p.x += rx * speed - p.z += rz * speed - } - if (keys.has("KeyA")) { - p.x -= rx * speed - p.z -= rz * speed - } - } - - /** Push the player's circle out of any solid it overlaps: level colliders it - * is not standing above, and the NPC. This is what makes walls and the NPC - * impassable while still letting you stand on the crate. */ - function collide(player: Player, level: Level): void { - for (const aabb of level.colliders) { - if (player.position.y < aabb.top - 0.01) { - pushFromAabb(player.position, aabb) - } - } - pushFromCircle(player.position, level.npcPosition.x, level.npcPosition.z, NPC_RADIUS) - } - - /** Apply gravity and land on the highest ground under the player. */ - function fall(player: Player, dt: number, level: Level): void { - player.velocityY -= GRAVITY * dt - player.position.y += player.velocityY * dt - const ground = groundHeight(player.position, level) - if (player.position.y <= ground) { - player.position.y = ground - player.velocityY = 0 - player.onGround = true - } else { - player.onGround = false - } - } - - function groundHeight(position: Vec3, level: Level): number { - let ground = Terrain.height(level.terrain, position.x, position.z) - for (const aabb of level.colliders) { - if ( - aabb.standable && - position.x >= aabb.minX && - position.x <= aabb.maxX && - position.z >= aabb.minZ && - position.z <= aabb.maxZ - ) { - ground = Math.max(ground, aabb.top) - } - } - return ground - } - - function pushFromAabb(position: Vec3, aabb: Aabb): void { - const cx = Math.max(aabb.minX, Math.min(aabb.maxX, position.x)) - const cz = Math.max(aabb.minZ, Math.min(aabb.maxZ, position.z)) - const dx = position.x - cx - const dz = position.z - cz - const d2 = dx * dx + dz * dz - if (d2 >= RADIUS * RADIUS) { - return - } - if (d2 > 1e-6) { - const d = Math.sqrt(d2) - const push = (RADIUS - d) / d - position.x += dx * push - position.z += dz * push - return - } - // Center is inside the box: eject through the nearest face. - const left = position.x - aabb.minX - const rightSide = aabb.maxX - position.x - const near = position.z - aabb.minZ - const far = aabb.maxZ - position.z - const m = Math.min(left, rightSide, near, far) - if (m === left) { - position.x = aabb.minX - RADIUS - } else if (m === rightSide) { - position.x = aabb.maxX + RADIUS - } else if (m === near) { - position.z = aabb.minZ - RADIUS - } else { - position.z = aabb.maxZ + RADIUS - } - } - - function pushFromCircle(position: Vec3, cx: number, cz: number, otherRadius: number): void { - const dx = position.x - cx - const dz = position.z - cz - const reach = RADIUS + otherRadius - const d2 = dx * dx + dz * dz - if (d2 >= reach * reach || d2 < 1e-6) { - return - } - const d = Math.sqrt(d2) - const push = (reach - d) / d - position.x += dx * push - position.z += dz * push } } diff --git a/game/renderScene.ts b/game/renderScene.ts deleted file mode 100644 index 96b0d7e..0000000 --- a/game/renderScene.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Framebuffer } from "../engine/render/Framebuffer" -import { Frustum } from "../engine/render/Frustum" -import { Rasterizer } from "../engine/render/Rasterizer" -import type { RenderConfig } from "../engine/render/RenderConfig" -import { Sky, type SkyConfig } from "../engine/render/Sky" -import type { Camera } from "../engine/scene/Camera" -import { Mat4 } from "../engine/math/Mat4" -import type { Mesh } from "../engine/scene/Mesh" -import { Mob, type MobKind } from "./actors/Mob" -import { Sprite } from "../engine/scene/Sprite" -import type { Vec2 } from "../engine/math/Vec2" -import type { Vec3 } from "../engine/math/Vec3" -import type { Textures } from "./textures" -import type { Chunk } from "./level" - -/** Everything needed to render the world: the room, the cullable chunks, the NPC - * billboard source, sky, and textures. Bundled so it can be handed to a worker - * whole (it is plain data + typed arrays, structured-clone friendly). */ -export type Scene = { - chunks: Chunk[] - floor: Mesh - walls: Mesh - crate: Mesh - npc: { position: Vec3; size: Vec2 } - /** Canonical local-space mob meshes, one per kind, built once + shared by every - * instance (each instance differs only by its per-frame model matrix). */ - mobMesh: Record - /** How many mobs the sim has -- sizes the worker's shared transform buffer. */ - mobCount: number - sky: SkyConfig - textures: Textures -} - -/** One mob's live transform for a frame: which mesh + where/how to place it. - * Produced by `visibleMobs` on the main thread, then either passed straight to - * `renderBand` (single-thread) or packed into the shared `mobState` buffer and - * rebuilt in each worker. `MOB_FLOATS` is that packed layout's stride. */ -export type MobDraw = { kind: MobKind; x: number; y: number; z: number; heading: number; scale: number } -export const MOB_FLOATS = 6 // kind index (into MOB_KINDS), x, y, z, heading, scale - -/** Chunk indices whose bounding box is inside the view frustum. Computed once on - * the main thread and shared with every worker (so they don't each re-cull). */ -export function visibleChunks(chunks: Chunk[], viewProj: Mat4): number[] { - const frustum = Frustum.fromViewProj(viewProj) - const out: number[] = [] - for (let i = 0; i < chunks.length; i++) { - const c = chunks[i] - if (Frustum.intersectsAabb(frustum, c.minX, c.minY, c.minZ, c.maxX, c.maxY, c.maxZ)) { - out.push(i) - } - } - return out -} - -/** The `MobDraw`s for mobs whose world AABB is inside the view frustum. Mobs move, - * so (unlike chunks) they can't be baked into the culled world -- they're culled - * here per frame instead. Computed once on the main thread; the visible set is - * what gets shipped to the workers. */ -export function visibleMobs(mobs: Mob[], viewProj: Mat4): MobDraw[] { - const frustum = Frustum.fromViewProj(viewProj) - const out: MobDraw[] = [] - for (const m of mobs) { - const r = Mob.boundingRadius(m.kind) * m.scale - const h = Mob.bodyHeight(m.kind) * m.scale - const p = m.position - if (Frustum.intersectsAabb(frustum, p.x - r, p.y - r, p.z - r, p.x + r, p.y + h + r, p.z + r)) { - out.push({ kind: m.kind, x: p.x, y: p.y, z: p.z, heading: m.heading, scale: m.scale }) - } - } - return out -} - -/** - * Render rows [y0, y1) of one frame into `fb`. This is the single source of - * render truth: the single-thread path calls it with the full height, and each - * worker calls it with its own disjoint band -- same output either way, and no - * two bands touch the same pixel (so the shared framebuffer needs no locking). - */ -export function renderBand( - fb: Framebuffer, - scene: Scene, - camera: Camera, - viewProj: Mat4, - visible: number[], - mobDraws: MobDraw[], - config: RenderConfig, - skyStep: number, - time: number, - y0: number, - y1: number, -): void { - const tx = scene.textures - Sky.render(fb, camera, scene.sky, time, skyStep, y0, y1) - // Room: small and always near, drawn unconditionally (double-sided). - Rasterizer.draw(fb, scene.floor, tx.floor, viewProj, config, false, y0, y1) - Rasterizer.draw(fb, scene.walls, tx.wall, viewProj, config, false, y0, y1) - Rasterizer.draw(fb, scene.crate, tx.crate, viewProj, config, false, y0, y1) - for (const i of visible) { - const c = scene.chunks[i] - // Past lodDistance, draw the cheap impostor group set instead of full detail. - // `chunkFar` is pure (camera + chunk bounds + config), so every worker band - // makes the identical choice -- no full/impostor seam across bands. The loop - // is content-agnostic: each group carries its own mesh + material. - const groups = chunkFar(c, camera.position, config.lodDistance) ? c.far : c.near - for (const g of groups) { - Rasterizer.draw(fb, g.mesh, g.material.texture, viewProj, config, g.material.cull, y0, y1) - } - } - const sprite: Sprite = { position: scene.npc.position, size: scene.npc.size, texture: tx.npc } - Rasterizer.draw(fb, Sprite.billboard(sprite, camera), tx.npc, viewProj, config, false, y0, y1) - // Roaming mobs: each is the shared local-space mesh for its kind, placed by its - // own model matrix (viewProj x model). Drawn double-sided (cull off) -- they're - // small and few, so the winding-correct backface cull isn't worth the fuss. - for (const m of mobDraws) { - const mvp = Mat4.multiply(viewProj, Mat4.compose(m.x, m.y, m.z, m.heading, m.scale)) - Rasterizer.draw(fb, scene.mobMesh[m.kind], tx[m.kind], mvp, config, false, y0, y1) - } - Framebuffer.quantize(fb, config, y0, y1) -} - -/** Whether a chunk is far enough to draw its impostor meshes: squared distance - * from the camera to the chunk's AABB vs `lodDistance²`. Pure -- depends only on - * camera, the chunk's baked bounds, and the config constant, all of which every - * worker already holds, so the choice is identical across bands. */ -export function chunkFar(chunk: Chunk, eye: Vec3, lodDistance: number): boolean { - if (!(lodDistance < Infinity)) { - return false - } - const dx = eye.x - Math.max(chunk.minX, Math.min(chunk.maxX, eye.x)) - const dy = eye.y - Math.max(chunk.minY, Math.min(chunk.maxY, eye.y)) - const dz = eye.z - Math.max(chunk.minZ, Math.min(chunk.maxZ, eye.z)) - return dx * dx + dy * dy + dz * dz > lodDistance * lodDistance -} diff --git a/meat.code-workspace b/meat.code-workspace index 7db0cb1..019843d 100644 --- a/meat.code-workspace +++ b/meat.code-workspace @@ -2,8 +2,11 @@ "folders": [ { "name": "meat", - "path": ".", + "path": "." }, + { + "path": "../meat.project" + } ], "settings": { "oxc.path.oxfmt": "node_modules/.bin/oxfmt", diff --git a/tests/actors.test.ts b/tests/actors.test.ts new file mode 100644 index 0000000..2f317ee --- /dev/null +++ b/tests/actors.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test" +import { Actor, type Actor as RuntimeActor, type ActorDefinition } from "../engine/scene/Actor" + +type World = { updates: string[] } + +const numberDefinition: ActorDefinition<{ value: number }, World> = { + prototype: { groups: [], radius: 1, minY: 0, maxY: 1 }, + update: (state, _dt, world) => { + state.value++ + world.updates.push(String(state.value)) + }, + transform: (state) => ({ x: state.value, y: 0, z: 0, heading: 0, scale: 1 }), +} + +const textDefinition: ActorDefinition<{ value: string }, World> = { + prototype: { groups: [], radius: 1, minY: 0, maxY: 1 }, + update: (state, _dt, world) => { + state.value += "!" + world.updates.push(state.value) + }, + transform: (state) => ({ x: state.value.length, y: 0, z: 0, heading: 0, scale: 1 }), +} + +test("one actor collection safely erases unrelated state types", () => { + const actors: RuntimeActor[] = [ + Actor.create(numberDefinition, { value: 1 }), + Actor.create(textDefinition, { value: "a" }), + ] + const world: World = { updates: [] } + + for (const actor of actors) { + Actor.update(actor, 1, world) + } + + expect(world.updates).toEqual(["2", "a!"]) + expect(Actor.transform(actors[0]).x).toBe(2) + expect(Actor.transform(actors[1]).x).toBe(2) +}) diff --git a/tests/layering.test.ts b/tests/layering.test.ts index b6cefe5..3d1fcb6 100644 --- a/tests/layering.test.ts +++ b/tests/layering.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" -import { readdirSync, readFileSync, statSync } from "node:fs" -import { join } from "node:path" +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" +import path from "node:path" // The engine is the reusable, content-agnostic layer: it must import nothing from // game (content) or app (browser glue). game -> engine and app -> game -> engine are @@ -9,7 +9,7 @@ import { join } from "node:path" function tsFiles(dir: string): string[] { const out: string[] = [] for (const name of readdirSync(dir)) { - const p = join(dir, name) + const p = path.join(dir, name) if (statSync(p).isDirectory()) { out.push(...tsFiles(p)) } else if (p.endsWith(".ts")) { @@ -19,14 +19,42 @@ function tsFiles(dir: string): string[] { return out } +function repositoryRoot(): string { + const parent = new URL("..", import.meta.url).pathname + return existsSync(path.join(parent, "app/renderer.ts")) ? parent : path.join(parent, "..") +} + +function importsLayer(source: string, layers: string): boolean { + return new RegExp(`(?:from\\s+|import\\s*(?:\\(\\s*)?)["'][^"']*/(?:${layers})/`).test(source) +} + test("engine imports nothing from game or app", () => { - const engineDir = new URL("../engine", import.meta.url).pathname - const offenders = tsFiles(engineDir).filter((f) => /from\s+["'][^"']*\/(?:game|app)\//.test(readFileSync(f, "utf8"))) + const engineDir = path.join(repositoryRoot(), "engine") + const offenders = tsFiles(engineDir).filter((file) => importsLayer(readFileSync(file, "utf8"), "game|app")) expect(offenders).toEqual([]) }) test("game imports nothing from app", () => { - const gameDir = new URL("../game", import.meta.url).pathname - const offenders = tsFiles(gameDir).filter((f) => /from\s+["'][^"']*\/app\//.test(readFileSync(f, "utf8"))) + const gameDir = path.join(repositoryRoot(), "game") + const offenders = tsFiles(gameDir).filter((file) => importsLayer(readFileSync(file, "utf8"), "app")) + expect(offenders).toEqual([]) +}) + +test("render driver and worker know no game content", () => { + const root = repositoryRoot() + const files = [path.join(root, "app/renderer.ts"), path.join(root, "app/render-worker.ts")] + const offenders = files.filter((file) => importsLayer(readFileSync(file, "utf8"), "game")) + expect(offenders).toEqual([]) +}) + +test("game exports no closed content-kind registries", () => { + const gameDir = path.join(repositoryRoot(), "game") + const offenders = tsFiles(gameDir).filter((file) => { + const source = readFileSync(file, "utf8") + return ( + /\b(?:export\s+)?(?:type|enum)\s+\w+Kind\b/.test(source) || + /\b(?:const|let|var)\s+\w*_(?:KINDS|REGISTRY)\b/.test(source) + ) + }) expect(offenders).toEqual([]) }) diff --git a/tests/level.test.ts b/tests/level.test.ts new file mode 100644 index 0000000..d101283 --- /dev/null +++ b/tests/level.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test" +import type { Texture } from "../engine/render/Texture" +import { buildLevel } from "../game/level" +import type { Textures } from "../game/textures" + +const texture: Texture = { width: 1, height: 1, data: new Uint32Array([0xFFFFFFFF]) } +const textures: Textures = { + floor: texture, + grass: texture, + bark: texture, + birch: texture, + leaf: texture, + needle: texture, + rock: texture, + flower: texture, + wall: texture, + crate: texture, + npc: texture, + frog: texture, + bee: texture, + robin: texture, + skybox: texture, +} + +test("game data compiles into a clone-safe engine render scene", () => { + const level = buildLevel(textures) + expect(level.actors).toHaveLength(60) + expect(Object.isFrozen(level.actors)).toBe(true) + expect(Object.isFrozen(level.render)).toBe(true) + expect(Object.isFrozen(level.render.prototypes)).toBe(true) + expect(level.render.prototypes).toHaveLength(3) + expect(level.render.maxInstances).toBe(level.actors.length) + expect(() => structuredClone(level.render)).not.toThrow() +}) + +test("actor prototype bounds cover local geometry", () => { + const level = buildLevel(textures) + for (const prototype of level.render.prototypes) { + let covered = true + for (const group of prototype.groups) { + const vertices = group.mesh.verts + for (let i = 0; i < vertices.length; i += 5) { + covered &&= + Math.abs(vertices[i]) <= prototype.radius + 1e-6 && + vertices[i + 1] >= prototype.minY - 1e-6 && + vertices[i + 1] <= prototype.maxY + 1e-6 && + Math.abs(vertices[i + 2]) <= prototype.radius + 1e-6 + } + } + expect(covered).toBe(true) + } +}) + +test("chunk bounds cover geometry in every LOD", () => { + const level = buildLevel(textures) + for (const chunk of level.render.chunks) { + let covered = true + for (const group of [...chunk.near, ...chunk.far]) { + const vertices = group.mesh.verts + for (let i = 0; i < vertices.length; i += 5) { + covered &&= + vertices[i] >= chunk.minX && + vertices[i] <= chunk.maxX && + vertices[i + 1] >= chunk.minY && + vertices[i + 1] <= chunk.maxY && + vertices[i + 2] >= chunk.minZ && + vertices[i + 2] <= chunk.maxZ + } + } + expect(covered).toBe(true) + } +}) diff --git a/tests/mobs.test.ts b/tests/mobs.test.ts deleted file mode 100644 index 89b5344..0000000 --- a/tests/mobs.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { expect, test } from "bun:test" -import { MOB_KINDS, Mob } from "../game/actors/Mob" - -// The mob SAB packs a kind as its index in MOB_KINDS; the main thread and every -// render worker must agree on that order. Freeze it here: appending a kind is fine, -// but reordering or removing an existing one silently corrupts which mesh/texture a -// worker draws. -test("MOB_KINDS order is frozen (mob SAB ids)", () => { - expect(MOB_KINDS).toEqual(["frog", "bee", "robin"]) -}) - -test("every kind resolves to a complete definition", () => { - for (const kind of MOB_KINDS) { - const d = Mob.def(kind) - expect(d.name).toBe(kind) - expect(typeof d.build).toBe("function") - expect(typeof d.update).toBe("function") - expect(d.boundingRadius).toBeGreaterThan(0) - expect(d.bodyHeight).toBeGreaterThan(0) - } -}) diff --git a/tests/render-protocol.test.ts b/tests/render-protocol.test.ts new file mode 100644 index 0000000..be64148 --- /dev/null +++ b/tests/render-protocol.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test" +import { RenderProtocol } from "../engine/render/RenderProtocol" +import type { RenderInstance } from "../engine/render/RenderScene" +import type { Camera } from "../engine/scene/Camera" + +test("render camera protocol owns its complete shared layout", () => { + const input: Camera = { position: { x: 1, y: 2, z: 3 }, yaw: 4, pitch: 5, fov: 6 } + const data = new Float64Array(RenderProtocol.CAMERA_LENGTH) + + RenderProtocol.writeCamera(data, input, 7) + const output = RenderProtocol.readCamera(data) + + expect(output).toEqual({ camera: input, time: 7 }) + expect(RenderProtocol.VIEW_PROJECTION_LENGTH).toBe(16) +}) + +test("render instance protocol round-trips scene-local prototype indexes", () => { + const input: RenderInstance[] = [ + { prototype: 2, x: 1.25, y: -2, z: 3.5, heading: 0.75, scale: 1.5 }, + { prototype: 0, x: -4, y: 5.25, z: 6, heading: -0.5, scale: 0.625 }, + ] + const ids = new Int32Array(2) + const transforms = new Float32Array(2 * RenderProtocol.TRANSFORM_FLOATS) + const output: RenderInstance[] = [] + + const count = RenderProtocol.writeInstances(ids, transforms, input) + RenderProtocol.readInstances(ids, transforms, count, output) + + expect(output).toHaveLength(input.length) + for (let i = 0; i < input.length; i++) { + expect(output[i].prototype).toBe(input[i].prototype) + expect(output[i].x).toBeCloseTo(input[i].x) + expect(output[i].y).toBeCloseTo(input[i].y) + expect(output[i].z).toBeCloseTo(input[i].z) + expect(output[i].heading).toBeCloseTo(input[i].heading) + expect(output[i].scale).toBeCloseTo(input[i].scale) + } +}) diff --git a/tests/trees.test.ts b/tests/trees.test.ts deleted file mode 100644 index 5056cf8..0000000 --- a/tests/trees.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect, test } from "bun:test" -import { Tree, TREE_KINDS } from "../game/actors/Tree" - -// The chunk baker (level.ts) accumulates geometry into a mesh per material key and -// only draws keys listed in MAT_ORDER. A tree species that declares a trunk/foliage -// material outside that palette would bake geometry that is silently never drawn. -// Freeze the palette here so a typo'd or unregistered material key fails a test. -const MATERIAL_KEYS = new Set(["grass", "rock", "bark", "birch", "leaf", "needle", "flower"]) - -test("every tree species maps to known chunk materials", () => { - for (const kind of TREE_KINDS) { - const s = Tree.species(kind) - expect(s.kind).toBe(kind) - expect(typeof s.build).toBe("function") - expect(MATERIAL_KEYS.has(s.trunk)).toBe(true) - expect(MATERIAL_KEYS.has(s.foliage)).toBe(true) - } -})