refactor: move world concepts into engine
This commit is contained in:
parent
eeedcb8e48
commit
2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions
|
|
@ -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<string>`, 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.
|
||||
714
.agents/plans/gpu.md
Normal file
714
.agents/plans/gpu.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -1 +0,0 @@
|
|||
../.agents/skills
|
||||
143
AGENTS.md
143
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<State, World>` 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/<Kind>.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/<Kind>.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<Tree>` 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/<Kind>.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<Tree>` 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/<Kind>.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).
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -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.
|
||||
|
||||
29
CONTEXT.md
Normal file
29
CONTEXT.md
Normal file
|
|
@ -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
|
||||
205
app/main.ts
205
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<HTMLCanvasElement>("#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<void> {
|
||||
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<MobKind, Mesh>
|
||||
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<void> {
|
|||
// 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<void> {
|
|||
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<string>()
|
||||
globalThis.addEventListener("keydown", (e) => {
|
||||
keys.add(e.code)
|
||||
|
|
@ -154,26 +157,30 @@ async function main(): Promise<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
* interval, then reports the distributions (exposed on `window.__BENCH__`). */
|
||||
function runBench(
|
||||
renderer: ReturnType<typeof createRenderer>,
|
||||
level: Level,
|
||||
level: ReturnType<typeof buildLevel>,
|
||||
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<string>): 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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
228
app/renderer.ts
228
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<ArrayBufferLike> = new Float64Array(0) // pos x/y/z, yaw, pitch, fov, time
|
||||
let vp: Float32Array<ArrayBufferLike> = new Float32Array(0) // the view-projection matrix
|
||||
let vis: Int32Array<ArrayBufferLike> = new Int32Array(0) // visible chunk indices
|
||||
let mob: Float32Array<ArrayBufferLike> = new Float32Array(0) // visible mob transforms (MOB_FLOATS each)
|
||||
let instanceIds: Int32Array<ArrayBufferLike> = new Int32Array(0)
|
||||
let instanceTransforms: Float32Array<ArrayBufferLike> = new Float32Array(0)
|
||||
let times: Float64Array<ArrayBufferLike> = 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
|
||||
}
|
||||
|
|
|
|||
3
docs/adr/0001-engine-owns-world-concepts.md
Normal file
3
docs/adr/0001-engine-owns-world-concepts.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
29
engine/render/Chunk.ts
Normal file
29
engine/render/Chunk.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
123
engine/render/ChunkBuilder.ts
Normal file
123
engine/render/ChunkBuilder.ts
Normal file
|
|
@ -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<Material, Mesh>()
|
||||
const farMeshes = new Map<Material, Mesh>()
|
||||
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<Material, Mesh>): 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<Material, Mesh>): 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
123
engine/render/RenderProtocol.ts
Normal file
123
engine/render/RenderProtocol.ts
Normal file
|
|
@ -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<ArrayBufferLike>,
|
||||
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<ArrayBufferLike>): 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<ArrayBufferLike>,
|
||||
viewProjection: Mat4,
|
||||
): void {
|
||||
output.set(viewProjection)
|
||||
}
|
||||
|
||||
export function writeInstances(
|
||||
ids: Int32Array<ArrayBufferLike>,
|
||||
transforms: Float32Array<ArrayBufferLike>,
|
||||
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<ArrayBufferLike>,
|
||||
transforms: Float32Array<ArrayBufferLike>,
|
||||
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],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
224
engine/render/RenderScene.ts
Normal file
224
engine/render/RenderScene.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<State, World> = {
|
||||
/** 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<State, World> = {
|
||||
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<World> = {
|
||||
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<State, World>(
|
||||
definition: ActorDefinition<State, World>,
|
||||
state: State,
|
||||
): Actor<World> {
|
||||
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<World>(actor: Actor<World>, dt: number, world: World): void {
|
||||
actor.updateState(dt, world)
|
||||
}
|
||||
|
||||
export function transform<World>(actor: Actor<World>): RenderTransform {
|
||||
return actor.readTransform()
|
||||
}
|
||||
|
||||
export function collider<World>(actor: Actor<World>, world: World): Collider | null {
|
||||
return actor.readCollider(world)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
80
engine/scene/MeshBuilder.ts
Normal file
80
engine/scene/MeshBuilder.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
38
engine/scene/Prefab.ts
Normal file
38
engine/scene/Prefab.ts
Normal file
|
|
@ -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<State> = {
|
||||
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<State>(
|
||||
definition: Prefab<State>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
89
engine/world/CharacterController.ts
Normal file
89
engine/world/CharacterController.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
116
engine/world/Collider.ts
Normal file
116
engine/world/Collider.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
62
engine/world/CollisionWorld.ts
Normal file
62
engine/world/CollisionWorld.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
123
engine/world/Level.ts
Normal file
123
engine/world/Level.ts
Normal file
|
|
@ -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<World> = {
|
||||
terrain: Terrain
|
||||
actorWorld: World
|
||||
actors: readonly RuntimeActor<World>[]
|
||||
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<World> = {
|
||||
readonly terrain: Terrain
|
||||
readonly actorWorld: World
|
||||
readonly actors: readonly RuntimeActor<World>[]
|
||||
readonly collision: Collision
|
||||
readonly render: Scene
|
||||
}
|
||||
|
||||
const prototypeIndexes = new WeakMap<object, ReadonlyMap<object, number>>()
|
||||
|
||||
export namespace Level {
|
||||
export function create<World>(definition: LevelDefinition<World>): Level<World> {
|
||||
const actors = Object.freeze([...definition.actors])
|
||||
const prototypes: RenderPrototype[] = []
|
||||
const prototypeIndex = new Map<object, number>()
|
||||
for (const actor of actors) {
|
||||
if (!prototypeIndex.has(actor.definition)) {
|
||||
prototypeIndex.set(actor.definition, prototypes.length)
|
||||
prototypes.push(actor.prototype)
|
||||
}
|
||||
}
|
||||
const level: Level<World> = {
|
||||
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<World>(level: Level<World>, dt: number): void {
|
||||
for (const actor of level.actors) {
|
||||
Actor.update(actor, dt, level.actorWorld)
|
||||
}
|
||||
}
|
||||
|
||||
export function visibleInstances<World>(
|
||||
level: Level<World>,
|
||||
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<World>(
|
||||
level: Level<World>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
engine/world/Terrain.ts
Normal file
121
engine/world/Terrain.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
106
game/Terrain.ts
106
game/Terrain.ts
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Boulder> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<Bush> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<FlowerColor, [number, number]> = {
|
||||
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<Flower> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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/<Kind>.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<Terrain>
|
||||
|
||||
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<MobKind, Entity<Mob, Terrain>> = { frog, bee, robin }
|
||||
|
||||
export namespace Mob {
|
||||
/** The definition for a kind (geometry, behavior, bounds). */
|
||||
export function def(kind: MobKind): Entity<Mob, Terrain> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<Kind>.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<TreeKind, TreeSpecies> = { 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Mob, Terrain> = {
|
||||
name: "bee",
|
||||
build,
|
||||
update,
|
||||
boundingRadius: 0.5,
|
||||
bodyHeight: 0.5,
|
||||
export type Bee = ActorDefinition<MobState, Ground>
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<Mob, Terrain> = {
|
||||
name: "frog",
|
||||
build,
|
||||
update,
|
||||
boundingRadius: 0.7,
|
||||
bodyHeight: 0.6,
|
||||
export type Frog = ActorDefinition<MobState, Ground>
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<Mob, Terrain> = {
|
||||
name: "robin",
|
||||
build,
|
||||
update,
|
||||
boundingRadius: 0.45,
|
||||
bodyHeight: 0.55,
|
||||
export type Robin = ActorDefinition<MobState, Ground>
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Tree>
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<Tree>
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<Tree>
|
||||
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
794
game/level.ts
794
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<Tree>
|
||||
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<MobState, Ground>
|
||||
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<Ground> {
|
||||
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<string, Mesh>()
|
||||
const far = new Map<string, Mesh>()
|
||||
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<string, Mesh>, 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<string, Mesh>, 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<Chunk, "minX" | "minY" | "minZ" | "maxX" | "maxY" | "maxZ"> | 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<Boulder>): 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<Bush>): 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<Flower>): 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<Tree> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
181
game/player.ts
181
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<string>, 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<string>): 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<string>, 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<string>, 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MobKind, Mesh>
|
||||
/** 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
|
||||
}
|
||||
|
|
@ -2,8 +2,11 @@
|
|||
"folders": [
|
||||
{
|
||||
"name": "meat",
|
||||
"path": ".",
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../meat.project"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"oxc.path.oxfmt": "node_modules/.bin/oxfmt",
|
||||
|
|
|
|||
38
tests/actors.test.ts
Normal file
38
tests/actors.test.ts
Normal file
|
|
@ -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<World>[] = [
|
||||
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)
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
|
|
|
|||
72
tests/level.test.ts
Normal file
72
tests/level.test.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
38
tests/render-protocol.test.ts
Normal file
38
tests/render-protocol.test.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue