sindicate 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/engine.js +284 -0
- package/src/index.js +13 -1
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- package/src/world/weather.js +312 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Shatter
|
|
2
|
+
|
|
3
|
+
`src/systems/shatter.js` — physics-y debris for smashed props (pots, urns, pre-fractured smashables). When something is smashed, `Shatter` clones the pack's real shard meshes (e.g. `SM_Prop_Vase_Shard_01..04`), flings them out with velocity and spin, drops them under gravity, bounces and settles them on the floor, then fades them out and removes them. It is pure CPU integration over a tiny transient list — no physics engine, no per-shard lights, nothing resident: shard meshes are added to the scene on smash and removed once faded.
|
|
4
|
+
|
|
5
|
+
Exported from the package barrel (`export * from './systems/shatter.js'` in `src/index.js`).
|
|
6
|
+
|
|
7
|
+
## Why it's built this way (PERF LAW)
|
|
8
|
+
|
|
9
|
+
The engine's WebGPU doctrine (see `ENGINE_DESIGN.md`, P2) is: everything resident at boot, never add/remove skinned meshes mid-play, fixed light counts, no pipeline compiles after boot. Shatter is the deliberate, minimal way to have mid-play debris inside that doctrine:
|
|
10
|
+
|
|
11
|
+
- Shards are plain static mesh clones — never skinned, never lit individually.
|
|
12
|
+
- The "pool" (`this.pieces`) is a tiny, short-lived array; each burst adds a handful of pieces and every piece removes itself after settle + fade. Nothing accumulates.
|
|
13
|
+
- All motion is closed-form CPU integration in `update(dt)` — no physics engine dependency.
|
|
14
|
+
- Cloned shard materials are of types the scene already renders, so bursts reuse existing pipelines. The design doc plans to make this a hard guarantee (shatter materials warm-registered at boot so the first mid-fight burst can never compile a pipeline); today it is discipline by convention — keep shard templates using materials that have already been rendered.
|
|
15
|
+
|
|
16
|
+
Note the word "pool" here means *bounded transient set*, not object reuse: clones are created per burst and fully disposed after fading.
|
|
17
|
+
|
|
18
|
+
## Injection
|
|
19
|
+
|
|
20
|
+
The scene is constructor-injected — the class holds no globals and touches nothing but the `THREE.Scene` you hand it. This file is part of the "verified-clean" migration tier: moved verbatim from the western game because it already imported clean, with the scene as its only external dependency.
|
|
21
|
+
|
|
22
|
+
## Exports
|
|
23
|
+
|
|
24
|
+
### `class Shatter`
|
|
25
|
+
|
|
26
|
+
#### `constructor(scene)`
|
|
27
|
+
|
|
28
|
+
- `scene` — the `THREE.Scene` shard meshes are added to and removed from.
|
|
29
|
+
|
|
30
|
+
Creates an empty pool (`this.pieces = []`).
|
|
31
|
+
|
|
32
|
+
#### `burst(pos, frags, opts = {})`
|
|
33
|
+
|
|
34
|
+
Spawn one burst of shards. No-op if `frags` is missing or empty.
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
burst(pos, frags, { count = 8, floorY = pos.y, spread = 2.4, up = 3.2, scale = 1, dir = null, ry = 0 } = {})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- `pos` — world `THREE.Vector3`, base of the smashed prop.
|
|
41
|
+
- `frags` — array of template `Object3D`s to clone (the shard meshes). Cycled with `i % frags.length` when `count` exceeds the template count.
|
|
42
|
+
- `count` — how many shards to throw (default 8).
|
|
43
|
+
- `floorY` — Y the shards rest on (default `pos.y`). Each piece actually settles at `floorY + 0.04..0.08` (random) so shards stack slightly instead of z-fighting on one plane.
|
|
44
|
+
- `spread` — horizontal fling speed scale (default 2.4).
|
|
45
|
+
- `up` — upward fling speed scale (default 3.2).
|
|
46
|
+
- `scale` — uniform scale multiplier applied to each clone (default 1).
|
|
47
|
+
- `dir` — optional unit `THREE.Vector3`; the blow "carries through": `spread * (0.5..0.9)` of extra velocity along `dir` is added to every shard.
|
|
48
|
+
- `ry` — the prop's yaw in radians. Only meaningful for pivot templates (below).
|
|
49
|
+
|
|
50
|
+
Two spawn modes, chosen per template:
|
|
51
|
+
|
|
52
|
+
1. **Pre-fractured smashables** — templates carrying `userData.pivot` (a local-space offset) spawn at their *true position inside the prop* (pivot rotated by `ry`, scaled, offset from `pos`), start in the prop's rest orientation (`rotation.set(0, ry, 0)`), and fly *outward from the prop's centre*, so the prop visibly breaks apart along its fracture planes instead of debris materialising at a point. These chunky shards tumble slower (`spinScale = 0.55`).
|
|
53
|
+
2. **Generic shards** (no pivot) — spawn in a small random cloud around `pos` with fully random orientation and random outward velocity.
|
|
54
|
+
|
|
55
|
+
Every clone gets **independent material instances** (`material.clone()`, arrays handled) so each shard can fade on its own without affecting the templates or its siblings.
|
|
56
|
+
|
|
57
|
+
#### `update(dt)`
|
|
58
|
+
|
|
59
|
+
Advance every piece one frame. Call this once per frame from the game loop. Early-returns when the pool is empty. `dt` is clamped to `0.04` s internally so a hitched frame can't tunnel shards through the floor.
|
|
60
|
+
|
|
61
|
+
Per-piece lifecycle:
|
|
62
|
+
|
|
63
|
+
1. **Flying** — gravity (`G = 17`, tuned snappy for low-poly debris), position integration, Euler spin.
|
|
64
|
+
2. **Bounce** — on crossing `floorY`: Y velocity reflected with restitution `REST = 0.34`, horizontal velocity kept at `FRICTION = 0.62` per bounce, spin damped to 45 %.
|
|
65
|
+
3. **Rest** — when a bounce leaves `|vel.y| < 0.7` and horizontal speed² `< 0.4`, the piece rests (spin zeroed).
|
|
66
|
+
4. **Settle hold** — a rested shard lingers `SETTLE_HOLD = 1.4` s.
|
|
67
|
+
5. **Fade** — opacity ramps to 0 over `FADE = 1.1` s (`transparent = true`, `depthWrite = false` forced on its materials).
|
|
68
|
+
6. **Removal** — at opacity 0 the mesh is removed from the scene, its cloned materials disposed, and the piece spliced out (the loop iterates backwards so splicing is safe).
|
|
69
|
+
|
|
70
|
+
#### `clear()`
|
|
71
|
+
|
|
72
|
+
Immediately remove every live shard from the scene, dispose their cloned materials, and empty the pool. Call on scene teardown / level change — pieces mid-flight will otherwise stay in the scene forever since only `update` retires them.
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
import * as THREE from 'three/webgpu';
|
|
78
|
+
import { Shatter } from 'sindicate';
|
|
79
|
+
|
|
80
|
+
const shatter = new Shatter(scene);
|
|
81
|
+
|
|
82
|
+
// Loaded once at boot: the pack's real shard meshes.
|
|
83
|
+
const vaseShards = [shard01, shard02, shard03, shard04];
|
|
84
|
+
|
|
85
|
+
// On smash — e.g. a melee hit lands on a pot:
|
|
86
|
+
function smashPot(pot, hitDir) {
|
|
87
|
+
shatter.burst(pot.position.clone(), vaseShards, {
|
|
88
|
+
count: 10,
|
|
89
|
+
floorY: pot.position.y, // pot sits on the floor
|
|
90
|
+
dir: hitDir, // unit vector — the blow carries through
|
|
91
|
+
scale: 0.9,
|
|
92
|
+
});
|
|
93
|
+
scene.remove(pot);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Pre-fractured prop: templates carry userData.pivot, so pass the prop's yaw
|
|
97
|
+
// and the shards fly apart along the fracture planes.
|
|
98
|
+
shatter.burst(crate.position.clone(), crate.userData.fragments, {
|
|
99
|
+
count: crate.userData.fragments.length,
|
|
100
|
+
ry: crate.rotation.y,
|
|
101
|
+
dir: hitDir,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Frame loop:
|
|
105
|
+
shatter.update(dt);
|
|
106
|
+
|
|
107
|
+
// Level teardown:
|
|
108
|
+
shatter.clear();
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Gotchas
|
|
112
|
+
|
|
113
|
+
- **Geometry is shared with the templates and must never be disposed.** Only the *materials* are cloned per shard (so each fades independently); the internal `disposeMats` helper frees exactly those clones on removal and deliberately leaves geometry alone. If you ever touch removal code, keep that asymmetry — disposing geometry here would destroy the templates for every future burst.
|
|
114
|
+
- **You must drive `update(dt)` every frame and call `clear()` on teardown.** There is no timer or scene hook; a Shatter that stops being updated leaves its shards frozen in the scene.
|
|
115
|
+
- **`dt` is clamped to 0.04 s** inside `update` — a long hitch slows the debris rather than letting shards tunnel through the floor or fly across the map.
|
|
116
|
+
- **Templates cycle.** `count` larger than `frags.length` reuses templates round-robin (`i % frags.length`); asking for 12 shards from 4 templates is fine and normal.
|
|
117
|
+
- **`userData.pivot` changes everything about a template**: spawn position (true position inside the prop, rotated by `ry`), initial orientation (prop's rest orientation, not random), outward-from-centre velocity, and slower tumble (`spinScale = 0.55`). Generic point-burst behaviour only applies to templates without it.
|
|
118
|
+
- **Fading mutates the cloned materials** (`transparent = true`, `depthWrite = false`). This is safe precisely because they are per-shard clones — another reason not to "optimise" the clone away.
|
|
119
|
+
- **Mid-play adds are the point, but keep them cheap.** The whole design exists to add/remove *only* tiny unskinned meshes mid-play, with materials whose pipelines already exist. Don't extend it to spawn lights, skinned meshes, or novel material types per burst — that breaks the PERF LAW this module was shaped by. (Future engine work warm-registers shatter materials at boot to enforce this; see `ENGINE_DESIGN.md`.)
|
|
120
|
+
- **Settle heights are jittered** (`floorY + 0.04..0.08`) so stacked shards don't z-fight on a single plane — if debris looks like it floats slightly, that offset is intentional.
|
|
121
|
+
- The tuning constants (`G = 17`, `REST = 0.34`, `FRICTION = 0.62`, `SETTLE_HOLD = 1.4`, `FADE = 1.1`) are module-private, not exported — they are tuned as a set for snappy low-poly debris, not meant as per-game knobs.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Core Utils & DecalField
|
|
2
|
+
|
|
3
|
+
Two small core modules:
|
|
4
|
+
|
|
5
|
+
- `src/core/utils.js` — shared math/noise helpers and scratch objects used across the engine.
|
|
6
|
+
- `src/core/decalField.js` — `DecalField`, a portable ground/surface decal system (blood splats, scorch marks, sigils, footprints) with a ring-buffer budget.
|
|
7
|
+
|
|
8
|
+
Both are re-exported from `src/index.js` (e.g. `export { DecalField } from './core/decalField.js'`).
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## utils.js
|
|
13
|
+
|
|
14
|
+
Small shared helpers. Imports `three/webgpu` and `ImprovedNoise` from three's addons.
|
|
15
|
+
|
|
16
|
+
### Exports
|
|
17
|
+
|
|
18
|
+
#### `noise: ImprovedNoise`
|
|
19
|
+
|
|
20
|
+
A single shared `ImprovedNoise` instance. `fbm` samples it; you can sample it directly with `noise.noise(x, y, z)`.
|
|
21
|
+
|
|
22
|
+
#### `mulberry32(seed) → () => number`
|
|
23
|
+
|
|
24
|
+
Deterministic pseudo-random generator for reproducible world generation. Returns a function that yields floats in `[0, 1)`. The seed is coerced with `seed >>> 0`, so any number is accepted; the same seed always produces the same sequence. Use this — not `Math.random()` — anywhere world-gen output must be reproducible across runs.
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const rng = mulberry32(1234);
|
|
28
|
+
rng(); // same value every run for seed 1234
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
#### `fbm(x, z, octaves = 4, lacunarity = 2, gain = 0.5, scale = 0.01) → number`
|
|
32
|
+
|
|
33
|
+
Fractal Brownian motion over the shared `noise` instance, sampled in the XZ plane. Each octave samples `noise.noise(x * freq, z * freq, 7.31 + i * 13.7)` — the third coordinate is a fixed per-octave slice offset, so octaves decorrelate but the field is fully determined by `(x, z)` and the parameters. The result is normalized by the total amplitude (`sum / norm`), so output stays in roughly `[-1, 1]` regardless of octave count.
|
|
34
|
+
|
|
35
|
+
#### `clamp(v, a, b) → number`
|
|
36
|
+
|
|
37
|
+
`Math.min(b, Math.max(a, v))`.
|
|
38
|
+
|
|
39
|
+
#### `lerp(a, b, t) → number`
|
|
40
|
+
|
|
41
|
+
`a + (b - a) * t`.
|
|
42
|
+
|
|
43
|
+
#### `smoothstep(a, b, t) → number`
|
|
44
|
+
|
|
45
|
+
Classic Hermite smoothstep: clamps `(t - a) / (b - a)` to `[0, 1]`, then `x * x * (3 - 2x)`. Note the argument order: edges first, value last.
|
|
46
|
+
|
|
47
|
+
#### `dist2D(ax, az, bx, bz) → number`
|
|
48
|
+
|
|
49
|
+
Euclidean distance in the XZ plane via `Math.sqrt(dx*dx + dz*dz)`.
|
|
50
|
+
|
|
51
|
+
> **Perf note (from the source):** `Math.hypot` is 5–10x slower in V8 — this is THE hot distance helper. Do not "clean it up" to `Math.hypot`; it is called constantly in hot paths.
|
|
52
|
+
|
|
53
|
+
#### `angleLerp(a, b, t) → number`
|
|
54
|
+
|
|
55
|
+
Shortest-arc angle interpolation for smooth turning. Wraps the delta into `(-π, π]` before lerping, so turning from `0.1` to `2π - 0.1` goes the short way through 0 instead of spinning almost a full turn.
|
|
56
|
+
|
|
57
|
+
#### `tmpV1`, `tmpV2`, `tmpV3: THREE.Vector3` · `tmpQ1: THREE.Quaternion`
|
|
58
|
+
|
|
59
|
+
Shared scratch objects for allocation-free math in per-frame code.
|
|
60
|
+
|
|
61
|
+
### Usage example
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { mulberry32, fbm, dist2D, angleLerp, clamp, tmpV1 } from './core/utils.js';
|
|
65
|
+
|
|
66
|
+
const rng = mulberry32(worldSeed); // reproducible per seed
|
|
67
|
+
const h = fbm(wx, wz, 5, 2, 0.5, 0.008); // terrain height sample, ~[-1, 1]
|
|
68
|
+
|
|
69
|
+
// hot path: chase steering
|
|
70
|
+
const d = dist2D(enemy.x, enemy.z, player.x, player.z);
|
|
71
|
+
if (d < aggroRange) {
|
|
72
|
+
const want = Math.atan2(player.x - enemy.x, player.z - enemy.z);
|
|
73
|
+
enemy.heading = angleLerp(enemy.heading, want, clamp(turnRate * dt, 0, 1));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
tmpV1.set(enemy.x, 0, enemy.z); // scratch — do not retain
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Gotchas
|
|
80
|
+
|
|
81
|
+
- **`dist2D` over `Math.hypot`, always.** The 5–10x V8 penalty is why this helper exists; it is the engine's hot distance function.
|
|
82
|
+
- **`mulberry32` for anything that must be reproducible.** World generation is seeded through it; mixing in `Math.random()` breaks determinism.
|
|
83
|
+
- **`tmpV1/2/3` and `tmpQ1` are shared module-level scratch.** Never store them, return them, or hold them across a call that might also use them — values are clobbered by the next user. Copy out if you need to keep a result.
|
|
84
|
+
- **`fbm` samples a fixed 3D slice** (`7.31 + i * 13.7` per octave); two calls with identical args always match, and octaves don't self-correlate.
|
|
85
|
+
- **`smoothstep(a, b, t)`** takes edges first — not the `(t)`-only GLSL convenience form.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## decalField.js
|
|
90
|
+
|
|
91
|
+
`DecalField` — portable ground/surface decals for the unity-vfx-webgpu format. Lays flat textured quads on the surface they are projected onto, oriented to the surface normal, with fade-in/out over life and a ring-buffer budget. Contains no game-specific code.
|
|
92
|
+
|
|
93
|
+
**These are simple laid-flat quads, NOT true projected decals** — there is no mesh-conforming UV projection. They are cheap, WebGPU-safe, and enough for a flat-ish ground surface. On sharply curved or stepped geometry a large decal will float or clip.
|
|
94
|
+
|
|
95
|
+
### `class DecalField`
|
|
96
|
+
|
|
97
|
+
#### `constructor(scene, { loadTexture, textureBase = '', max = 128 } = {})`
|
|
98
|
+
|
|
99
|
+
Injection points — the field owns no loader or scene of its own:
|
|
100
|
+
|
|
101
|
+
- `scene` — a `THREE.Scene` (or any `Object3D` container) that decal meshes are added to / removed from.
|
|
102
|
+
- `loadTexture(nameOrUrl) → Promise<Texture>` — injected texture loader. All texture I/O goes through this.
|
|
103
|
+
- `textureBase` — when set, bare basenames in recipes resolve to `${textureBase}/${name}.png` (normal maps too). When absent, the basename is passed to `loadTexture` unchanged.
|
|
104
|
+
- `max` — ring-buffer budget. When the `(max + 1)`th decal is added, the oldest is evicted (removed from scene, geometry and material disposed).
|
|
105
|
+
|
|
106
|
+
Public state: `recipes` (name → recipe), `textures` (basename → `THREE.Texture` or `null` on load failure), `items` (active decals, oldest first — the ring buffer).
|
|
107
|
+
|
|
108
|
+
#### `async load(decalRecipes)`
|
|
109
|
+
|
|
110
|
+
Stores the recipe table and pre-resolves every texture (and normal map) referenced by any recipe, via the injected `loadTexture`. A failed load warns `[decal] missing tex <name>` and stores `null` — later `add()` calls for that recipe render an untextured tinted quad rather than throwing. Call once at init, before `add()`.
|
|
111
|
+
|
|
112
|
+
**Recipe schema** (all fields optional):
|
|
113
|
+
|
|
114
|
+
| Field | Type | Meaning |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `tex` | string | Albedo texture basename/URL |
|
|
117
|
+
| `norm` | string | Normal-map basename (preloaded — see gotchas) |
|
|
118
|
+
| `sz` | `[min, max]` | Base size range in metres, sampled uniformly per spawn (default `[1, 1]`) |
|
|
119
|
+
| `aspect` | number | Width = `size * aspect`, height = `size` (default 1, square) |
|
|
120
|
+
| `tint` | `[r, g, b, a]` | Authored color/alpha multiplier (default `[1,1,1,1]`) |
|
|
121
|
+
| `blend` | `'a'` | Additive blending; anything else = normal blending |
|
|
122
|
+
| `rot` | `'rand'` \| degrees | Spin about the surface normal |
|
|
123
|
+
| `yOffset` | number | Lift along the normal in metres (default `0.02`) |
|
|
124
|
+
| `life` | number | Seconds until auto-removal; `0`/absent = persistent |
|
|
125
|
+
| `fade` | `[in, out]` | Fade-in / fade-out durations in seconds |
|
|
126
|
+
|
|
127
|
+
#### `add(name, { pos, normal = [0, 1, 0], scale = 1, rot, color } = {}) → { decal, remove } | null`
|
|
128
|
+
|
|
129
|
+
Lays a decal from recipe `name` at world `pos` (accepts `{x,y,z}` or `[x,y,z]`), oriented so its face points along `normal` (an `[x,y,z]` array; degenerate normals fall back to `+Y`). Returns `null` if the recipe doesn't exist.
|
|
130
|
+
|
|
131
|
+
- `scale` multiplies the authored size.
|
|
132
|
+
- `rot` overrides the recipe's rotation (`'rand'` or degrees).
|
|
133
|
+
- `color` multiplies the authored tint — accepts an `[r,g,b,(a)]` array, a `THREE.Color`, or anything `new THREE.Color(color)` accepts (only arrays can scale alpha).
|
|
134
|
+
|
|
135
|
+
Internals worth knowing:
|
|
136
|
+
|
|
137
|
+
- Geometry is a fresh `PlaneGeometry`; three's plane faces `+Z`, so orientation is `setFromUnitVectors(+Z, normal)`, then the spin quaternion is premultiplied about the normal.
|
|
138
|
+
- Material is `MeshBasicMaterial` with `transparent: true`, `depthWrite: false`, `polygonOffset` (`factor/units = -4`), `DoubleSide`, `fog: false`. polygonOffset beats z-fighting against the surface the decal lies on; depthWrite off + transparent makes overlapping splats blend instead of clipping each other.
|
|
139
|
+
- Position is lifted along the normal by `yOffset` (default 0.02 m) as a second z-fighting guard.
|
|
140
|
+
- Opacity spawns at the correct first-frame alpha — `0` when fading in — so decals never pop for one frame at full alpha.
|
|
141
|
+
- After pushing, the ring buffer evicts oldest items past `max` (scene remove + geometry/material dispose).
|
|
142
|
+
|
|
143
|
+
The returned handle's `remove()` deletes that specific decal early, regardless of its life.
|
|
144
|
+
|
|
145
|
+
#### `update(dt)`
|
|
146
|
+
|
|
147
|
+
Advance ages, apply fades, expire dead decals. Iterates backwards so expiry splices are safe. `life > 0` decals are disposed when `age >= life`; fade-in ramps alpha up over `fade[0]`, fade-out ramps down over the last `fade[1]` seconds. Persistent decals (`life` 0/absent) fade in and never fade out. **Must be called every frame** for fades and expiry to happen.
|
|
148
|
+
|
|
149
|
+
#### `clear()`
|
|
150
|
+
|
|
151
|
+
Dispose every active decal and empty the buffer (level unload / restart).
|
|
152
|
+
|
|
153
|
+
### Usage example
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import { DecalField } from 'sindicate';
|
|
157
|
+
|
|
158
|
+
const decals = new DecalField(scene, {
|
|
159
|
+
loadTexture: (url) => textureLoader.loadAsync(url),
|
|
160
|
+
textureBase: 'assets/decals', // 'blood' -> assets/decals/blood.png
|
|
161
|
+
max: 96,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await decals.load({
|
|
165
|
+
blood: { tex: 'blood', sz: [0.6, 1.2], rot: 'rand', tint: [0.7, 0.05, 0.05, 0.9], life: 20, fade: [0.1, 4] },
|
|
166
|
+
scorch: { tex: 'scorch', sz: [1.5, 2.0], rot: 'rand', blend: 'a', life: 0, fade: [0.2, 0] }, // persistent
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// on hit:
|
|
170
|
+
const h = decals.add('blood', { pos: hit.point, normal: [hit.normal.x, hit.normal.y, hit.normal.z], scale: 1.3 });
|
|
171
|
+
// h.remove() to delete early
|
|
172
|
+
|
|
173
|
+
// per frame:
|
|
174
|
+
decals.update(dt);
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Gotchas
|
|
178
|
+
|
|
179
|
+
- **Not projected decals.** Flat quads oriented to a single normal — intentional: cheap and WebGPU-safe. Don't expect them to wrap stairs, rocks, or walls-meeting-floors.
|
|
180
|
+
- **Ring buffer is the only budget.** Nothing throttles `add()`; spamming it just evicts your oldest decals (including "persistent" ones — persistence only means no lifetime expiry, not eviction immunity). Size `max` for the worst-case burst you care about.
|
|
181
|
+
- **Call `update(dt)` every frame.** Fades and `life` expiry live there; skip it and timed decals never die.
|
|
182
|
+
- **`load()` before `add()`.** `add()` reads `this.textures` synchronously; unresolved textures render as untextured tinted quads (missing textures warn once at load, then silently degrade).
|
|
183
|
+
- **Two z-fighting defenses, keep both.** `polygonOffset(-4, -4)` plus the `yOffset` lift along the normal (default 0.02 m). Removing either brings the flicker back on coplanar ground.
|
|
184
|
+
- **`depthWrite: false` is load-bearing.** Overlapping splats must blend; with depth writes on they clip each other.
|
|
185
|
+
- **First-frame alpha is set at spawn** (`0` when `fadeIn > 0`, else the composed alpha) specifically so decals never pop at full opacity for a frame before the first `update()`.
|
|
186
|
+
- **`norm` maps are preloaded but not currently applied.** `load()` fetches them into `this.textures`, but `add()` builds a `MeshBasicMaterial` with only `map` — the recipe field is plumbed for the format, not yet wired into the material.
|
|
187
|
+
- **Per-decal geometry and material.** Each `add()` allocates a `PlaneGeometry` + `MeshBasicMaterial`; both are disposed on eviction/expiry/`clear()`. If you bypass `remove()`/`clear()` and pull meshes out yourself, you leak GPU resources.
|
|
188
|
+
- **Spawn randomness uses `Math.random()`** (size within `sz`, `'rand'` rotation) — decal placement is not part of the deterministic `mulberry32` world-gen path.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Water
|
|
2
|
+
|
|
3
|
+
`src/world/water.js`, `src/world/waterNoise.js`
|
|
4
|
+
|
|
5
|
+
Low-poly water with **see-through depth**. The core techniques are ported from Braffolk's fable5-world-demo `WaterMaterial` (same three.js/TSL stack), restyled for the faceted Synty look. The surface refracts the actual rendered scene behind it (`viewportSharedTexture`) with per-channel Beer–Lambert absorption by water-column thickness, and reflects the sky-phase colours (`uSkyCol` / `uHorizonCol` from `weatherUniforms.js`) by fresnel — so riverbeds and wading legs show through the shallows, deep water goes murky, and the water tracks dawn/dusk/night/moor-dread for free. Kept from the house style: the gentle faceted swell, per-vertex `aDepth` (foam + inside-gating), the pond's `rClip` sprawl guard, and dive-under-terrain shoreline geometry.
|
|
6
|
+
|
|
7
|
+
Both materials are `THREE.MeshBasicNodeMaterial` (TSL node materials, WebGPU renderer) with `transparent: true, depthWrite: true`.
|
|
8
|
+
|
|
9
|
+
## Exports
|
|
10
|
+
|
|
11
|
+
### `waterMaterial(options = {}) → THREE.MeshBasicNodeMaterial`
|
|
12
|
+
|
|
13
|
+
Still water: lakes, ponds, the sea.
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
waterMaterial({
|
|
17
|
+
foam = 0xeef8f6, // foam colour
|
|
18
|
+
shallow = 0x3f96a8, // baked-ramp shallow colour (only used when refract: false)
|
|
19
|
+
deep = 0x114f78, // baked-ramp deep colour (only used when refract: false)
|
|
20
|
+
maxDepth = 2.0, // aDepth at which the baked ramp reaches `deep`
|
|
21
|
+
foamDepth = 0.18, // water depth (m) under which shoreline foam appears
|
|
22
|
+
rClip = 21, // pond radius clip — a number, or 'attribute' (see below)
|
|
23
|
+
waveHeight = 0.16, // vertical swell amplitude (local Z)
|
|
24
|
+
waveLen = 9, // swell wavelength (m)
|
|
25
|
+
waveSpeed = 0.7, // swell + foam animation rate
|
|
26
|
+
refract = true, // false = cheap baked shallow→deep ramp, no viewport copy
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Geometry contract.** The mesh is authored in local space with the pond centred at the local origin: `positionLocal.xy` is the water plane (so `positionLocal.xy.length()` is distance from pond centre) and local Z is up — the swell is *added* to the baked local Z. Shore vertices are baked **below** the terrain so the sheet dives under dry ground and the waterline is the natural surface intersection. Required attributes:
|
|
31
|
+
|
|
32
|
+
| attribute | type | meaning |
|
|
33
|
+
|-----------|-------|---------|
|
|
34
|
+
| `aDepth` | float | baked water depth at the vertex; 0 on the dry dive-skirt verts (gates opacity, drives foam and the non-refracting colour ramp) |
|
|
35
|
+
| `aRClip` | float | per-vertex clip radius — **only** when `rClip: 'attribute'` |
|
|
36
|
+
|
|
37
|
+
**`rClip` and material sharing.** `rClip` may be a number (one body per material — the sea) or the string `'attribute'`: lakes bake their radius per-vertex (`aRClip`) so **every lake shares one material**. This matters for cost, not just convenience — each distinct refracting material compiles its own `viewportSharedTexture` sampling, and each ViewportTexture node pays its own `copyFramebufferToTexture` per frame. N lakes on one material is **1 framebuffer copy, not N**.
|
|
38
|
+
|
|
39
|
+
**`refract: false`** replaces the viewport refraction with a baked `shallow → deep` colour ramp keyed on `aDepth / maxDepth`. Used for the sea: too big to pay viewport copies for water you can't see into anyway.
|
|
40
|
+
|
|
41
|
+
Foam appears wherever the water *ends* — at the shallow waterline (`aDepth → 0`) **and** at the clip radius — both edges wave-jittered.
|
|
42
|
+
|
|
43
|
+
### `riverMaterial(options = {}) → THREE.MeshBasicNodeMaterial`
|
|
44
|
+
|
|
45
|
+
Flowing river water: a ribbon mesh (built in `zones.js`) at world Y, `side: THREE.DoubleSide`. Same see-through refraction + sky fresnel as the lakes; a flowmap ripple layer and advected foam make the current read.
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
riverMaterial({
|
|
49
|
+
foam = 0xeef8f6,
|
|
50
|
+
shallow = 0x4494a4, // baked-ramp colours, only used when refract: false
|
|
51
|
+
deep = 0x11557a,
|
|
52
|
+
maxDepth = 1.3,
|
|
53
|
+
foamDepth = 0.2, // NOTE: currently accepted but unused in the body
|
|
54
|
+
flowSpeed = 0.5, // NOTE: currently accepted but unused in the body
|
|
55
|
+
refract = true,
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Rivers use a denser in-scatter murk than lakes (`vec3(0.22, 0.30, 0.32)` vs `vec3(0.10, 0.16, 0.17)`) — their plain beds give refraction little to show until the riverbed-texturing pass lands, so the murk gives the stream a visible body.
|
|
60
|
+
|
|
61
|
+
Required attributes:
|
|
62
|
+
|
|
63
|
+
| attribute | type | meaning |
|
|
64
|
+
|-----------|-------|---------|
|
|
65
|
+
| `aDepth` | float | baked water depth; 0 on the dry dive-skirt verts |
|
|
66
|
+
| `aFlow` | vec2 | per-vertex downstream flow velocity (world XZ) — advects ripples and foam; its length scales ripple amplitude |
|
|
67
|
+
| `aDrop` | float | baked downstream water-level drop — rapids foam keys on this, so real rapids appear exactly where the stepped monotonic river profile steps down |
|
|
68
|
+
|
|
69
|
+
### `waterNoiseTexture() → THREE.DataTexture`
|
|
70
|
+
|
|
71
|
+
`src/world/waterNoise.js`. CPU-baked tileable water noise — braffolk's NoiseBake, boot-time edition (~10 ms). A **cached singleton**: the first call bakes, every later call returns the same texture (also consumed by `grass.js` — do not dispose it per-material).
|
|
72
|
+
|
|
73
|
+
One 256² RGBA **float** texture, `RepeatWrapping`, linear min/mag filters:
|
|
74
|
+
|
|
75
|
+
| channel | contents |
|
|
76
|
+
|---------|----------|
|
|
77
|
+
| `r` | fbm (three periodic value-noise octaves, 8/16/32 lattices, weights 0.55/0.3/0.15) |
|
|
78
|
+
| `g` | decorrelated fbm (different seed + offsets) — used for foam patterns |
|
|
79
|
+
| `b`, `a` | d(fbm)/du, d(fbm)/dv — central differences, packed as `gradient * 0.02 + 0.5` |
|
|
80
|
+
|
|
81
|
+
Because the gradients are pre-derived and packed, a ripple layer costs **one texture fetch** (the shader unpacks with `.zw.sub(0.5).div(0.02 * scale)`). The lattices use a deterministic LCG, so the bake is reproducible.
|
|
82
|
+
|
|
83
|
+
## The viewport-refraction approach
|
|
84
|
+
|
|
85
|
+
The internal `refraction(n, murk)` helper (shared by both materials) is where the see-through look comes from:
|
|
86
|
+
|
|
87
|
+
1. **Sample the scene behind the surface.** `screenUV` is offset by the surface normal's XZ (`n.xz * refrK`) — the ripple/facet-driven refraction wobble. `refrK = clamp(9/dist, 0.04, 1) * 0.05` shrinks the offset with camera distance so far water doesn't shimmer wildly.
|
|
88
|
+
2. **Depth-validate the offset UV.** The refracted sample's view-Z (via `viewportDepthTexture`) is compared against the water fragment's own Z. If the sample landed on geometry **in front of** the water (a wader, a bridge post — `zR > fragZ + 0.02`), the shader falls back to the straight `screenUV`. Without this, foreground geometry smears into the water surface.
|
|
89
|
+
3. **Beer–Lambert absorption.** Water thickness along the view ray = `fragZ − zS` (metres, from the depth texture). Per-channel transmittance `T = exp(−1.3 · thick · σ)` with σ = `(0.55, 0.24, 0.17)` — UK water: red dies first, so depths go cool green-blue and murky by ~2.5 m.
|
|
90
|
+
4. **Turbidity in-scatter.** `uSkyCol · murk` fills in what absorption removes (`col = scene·T + inscat·(1−T)`), so the murk brightens by day and dims at night automatically.
|
|
91
|
+
5. **Vertical column.** `vDepth = thick · max(|viewDirY|, 0.06)` — the *vertical* water column, used for the per-pixel shoreline feather (mm-deep water fades over the bed) and shore foam.
|
|
92
|
+
|
|
93
|
+
On top of the refracted base: fresnel-weighted sky reflection along the reflected ray, reproducing the sky dome's horizon→sky ramp, darkened ×0.78.
|
|
94
|
+
|
|
95
|
+
## Flowmap ripples and foam (rivers)
|
|
96
|
+
|
|
97
|
+
Faithful port of braffolk `WaterMaterial` 132–151 (ripples) and 283–308 (foam):
|
|
98
|
+
|
|
99
|
+
- **No-sliding-texture flowmap:** two phases half a cycle apart (`ph1`, `ph2 = ph1 + 0.5`), each advecting the noise along `aFlow`, crossfaded by `w2 = |ph1 − 0.5| · 2` so each phase's snap-back is hidden. A small constant velocity `(0.045, 0.03)` is the breeze fallback where `aFlow` ≈ 0.
|
|
100
|
+
- **One fetch per ripple layer:** slope comes straight from the pre-baked gradient channels. Two scales (0.9 and 3.4 tiles) sum into each layer. Ripple amplitude `0.007 + |aFlow| · 0.028` — their tuned value; the ripple normal is added **under** the facet silhouette (`normalize(facetN + (−slope.x, 0, −slope.y))`) so ripples wobble the refraction downstream without breaking the low-poly read.
|
|
101
|
+
- **Foam:** two decorrelated advected noise scales are blended and then **multiplied** into clumpy patches — a single thresholded fbm slides as coherent stripes. The crossfade is variance-renormalised (`÷ sqrt(w2² + (1−w2)²)`) so foam coverage doesn't pulse at blend midpoints. Shore foam keys on the per-pixel water column (`R.vDepth`); rapids foam keys on the baked `aDrop`. Total foam is capped at 0.68.
|
|
102
|
+
- **Opacity body term:** `smoothstep(0.1, 0.7, aDepth) · 0.55` guarantees the channel core always reads as *water*, not damp ground, even where refraction feathers thin.
|
|
103
|
+
|
|
104
|
+
## Usage
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
import * as THREE from 'three/webgpu';
|
|
108
|
+
import { waterMaterial, riverMaterial } from './world/water.js';
|
|
109
|
+
|
|
110
|
+
// ONE material for every lake — radius baked per-vertex means one
|
|
111
|
+
// framebuffer copy per frame no matter how many lakes are visible.
|
|
112
|
+
const lakeMat = waterMaterial({ rClip: 'attribute' });
|
|
113
|
+
|
|
114
|
+
// Pond disc authored in local XY (centre at origin, local Z = up),
|
|
115
|
+
// shore ring baked BELOW the terrain so the sheet dives under dry ground.
|
|
116
|
+
geo.setAttribute('aDepth', new THREE.BufferAttribute(depths, 1)); // 0 on the dry skirt
|
|
117
|
+
geo.setAttribute('aRClip', new THREE.BufferAttribute(radii, 1));
|
|
118
|
+
const lake = new THREE.Mesh(geo, lakeMat);
|
|
119
|
+
lake.rotation.x = -Math.PI / 2; // orient local +Z to world up
|
|
120
|
+
scene.add(lake);
|
|
121
|
+
|
|
122
|
+
// The sea: baked depth ramp, no viewport copy.
|
|
123
|
+
const seaMat = waterMaterial({ refract: false, rClip: 4000 });
|
|
124
|
+
|
|
125
|
+
// A river ribbon (geometry from zones.js: aDepth + aFlow + aDrop baked in).
|
|
126
|
+
const river = new THREE.Mesh(riverGeo, riverMaterial());
|
|
127
|
+
scene.add(river);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Gotchas
|
|
131
|
+
|
|
132
|
+
- **Fresnel uses a hard-flattened normal.** Braffolk's lesson: the reflectance weight must follow the *mean* surface — feed the tilted per-facet normal into `(1−cosθ)^5` and big facet tilts saturate it into a white ice sheet. `fresnel()` flattens X/Z ×0.25 for the mix weight only; the full normal still shapes the reflected sky colour and shimmer, so the facets still sparkle. Related: fresnel is capped ×0.5 so the refracted bed always survives underneath, and river `rippleAmp` is tuned small for the same reason — bigger amplitudes saturate fresnel into the same white sheet.
|
|
133
|
+
- **Refraction UV must stay depth-validated.** A refracted sample landing on geometry in front of the water must fall back to the straight UV, or foreground geometry (a wader, a bridge post) smears into the surface. If you touch `refraction()`, keep the `zR > fragZ + 0.02` guard.
|
|
134
|
+
- **One material = one framebuffer copy.** Every distinct refracting material compiles its own `viewportSharedTexture` sampling and pays its own `copyFramebufferToTexture` per frame. Share materials across bodies via `rClip: 'attribute'` + `aRClip`; never instantiate a refracting material per lake.
|
|
135
|
+
- **Build the expensive nodes once.** In both materials the shared nodes (`R`, `skyShared`, `fresShared`, the facet normal) are constructed *outside* `colorNode`/`opacityNode` and referenced from both — separate calls would duplicate the viewport-texture sampling in the compiled shader. Preserve this structure when editing.
|
|
136
|
+
- **`refract: false` exists for a reason.** The sea is too big to pay viewport copies for water you can't see into — it takes the cheap baked shallow→deep ramp instead.
|
|
137
|
+
- **Sky reflection is darkened ×0.78.** Real water reflects darker than the sky (micro-roughness), and a full-brightness midday sky whites out narrow streams seen at grazing angles.
|
|
138
|
+
- **The whole surface is dimmed by sky brightness** (base + foam, not just the reflection): `lit = clamp(luma(uSkyCol)·0.55, 0.22, 1)`. Without it water glows white at dusk/night; the 0.22 floor keeps it readable in darkness.
|
|
139
|
+
- **Shoreline geometry does the waterline.** Shore verts are baked below the terrain; the swell is *added* to that baked local Z, so the waterline is the natural mesh/terrain intersection — don't "fix" verts that appear underground.
|
|
140
|
+
- **River murk is deliberately denser than lake murk** — the plain riverbeds give refraction little to show until the riverbed-texturing pass lands.
|
|
141
|
+
- **`riverMaterial`'s `foamDepth` and `flowSpeed` options are currently dead** — accepted in the signature but not referenced in the body (foam thresholds and flow timing are hard-coded to braffolk's tuned constants).
|
|
142
|
+
- **Foam must multiply, not threshold.** Two decorrelated advected scales multiplied give clumpy patches; a single thresholded fbm slides past as coherent stripes. Keep the variance-renormalised crossfade or coverage pulses at blend midpoints.
|
|
143
|
+
- **`waterNoiseTexture()` is a shared singleton** (also used by `grass.js`); its gradients are packed as `g·0.02 + 0.5`, so any consumer must unpack with `.sub(0.5).div(0.02 · scale)` — the 0.02 constant appears on both sides of the bake.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Wind & Weather Uniforms
|
|
2
|
+
|
|
3
|
+
Modules: `src/world/wind.js`, `src/world/weatherUniforms.js`
|
|
4
|
+
Both are re-exported from the engine root (`src/index.js`).
|
|
5
|
+
|
|
6
|
+
Together these two files are the single source of weather truth for the whole scene. `Wind` is a global wind simulation ticked once per frame on the CPU; `weatherUniforms.js` is a set of module-level TSL uniform singletons that carry the current weather/sky state into every shader that opts in. Everything that should lean on the weather reads from here instead of rolling its own — CPU consumers (e.g. tumbleweeds) read `wind.vx/vz` directly, GPU consumers (grass sway, rain slant, water tint, ground wetness) read the shared uniforms.
|
|
7
|
+
|
|
8
|
+
## Why uniforms decouple build order
|
|
9
|
+
|
|
10
|
+
The uniforms are **module-level singletons** created at import time. Terrain, grass, water, and the Weather/sky systems may be constructed in any order — some materials are built before the weather system exists, some after — but because every one of them imports the *same* uniform node objects, they all reference identical GPU state no matter who was built first. The writer (Weather, sky, `Wind`) just mutates `.value` each frame and every material that baked the node into its shader graph sees the change. No registration step, no "pass the weather system into the terrain constructor" plumbing, no ordering constraint.
|
|
11
|
+
|
|
12
|
+
## Exports — `world/wind.js`
|
|
13
|
+
|
|
14
|
+
### `class Wind`
|
|
15
|
+
|
|
16
|
+
One slowly-wandering heading plus a gusting strength. Deterministic layered sines drive both the drift and the gusts — smooth, no per-frame RNG jerks, and identical after a reload.
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
const wind = new Wind(); // publishes initial state to uWindDir/uWindStr immediately
|
|
20
|
+
wind.update(dt); // call once per frame; dt in seconds
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**`constructor()`** — initializes state and immediately publishes to the shared uniforms (so shaders see sane wind even before the first `update`).
|
|
24
|
+
|
|
25
|
+
**`update(dt)`** — advances the internal clock, recomputes heading/gust/speed, republishes. Call exactly once per frame.
|
|
26
|
+
|
|
27
|
+
Instance fields (all readable; treat as read-only outside the class):
|
|
28
|
+
|
|
29
|
+
| Field | Meaning |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `t` | accumulated sim time (seconds) |
|
|
32
|
+
| `baseAngle` | the PREVAILING heading, fixed at `0.7` rad (~SE-ish); the wind only veers gently around it |
|
|
33
|
+
| `angle` | current heading the wind blows **toward** — radians on the ground plane, `atan2(z, x)` convention. Oscillates `baseAngle ± ~0.47` rad (~±27°) |
|
|
34
|
+
| `base` | mean speed, `1.6` m/s — a light breeze for the CPU consumers, not a gale |
|
|
35
|
+
| `gust` | current gust factor, swells and lulls between ~`0.12` and ~`1.05` |
|
|
36
|
+
| `speed` | current speed, `base * (0.5 + 0.85 * gust)` ≈ 0.96–2.36 m/s |
|
|
37
|
+
| `x`, `z` | unit heading components (`cos(angle)`, `sin(angle)`) |
|
|
38
|
+
| `vx`, `vz` | velocity in m/s (`x * speed`, `z * speed`) — what CPU consumers integrate against |
|
|
39
|
+
|
|
40
|
+
**`_publish()`** *(private)* — copies `x/z` into `uWindDir` and `gust` into `uWindStr`. Called from the constructor and every `update`; you never call it yourself.
|
|
41
|
+
|
|
42
|
+
## Exports — `world/weatherUniforms.js`
|
|
43
|
+
|
|
44
|
+
Every export is a `uniform(...)` node from `three/tsl` (three/webgpu build). Writers mutate `.value` on the CPU; readers compose the node into TSL material graphs.
|
|
45
|
+
|
|
46
|
+
| Uniform | Initial value | Range / meaning | Written by | Read by |
|
|
47
|
+
|---|---|---|---|---|
|
|
48
|
+
| `uWet` | `0` | 0 dry … 1 soaked — rain darkens the ground | Weather system, **eased** each frame so the ground soaks/dries gradually | `grass.js` (blade colour darkens toward ×0.7 when soaked); terrain ground darkening, puddles, and sheen (per source comments — host-game side) |
|
|
49
|
+
| `uCover` | `0` | 0 bare … 1 snow-covered | Weather system, eased so snow builds/melts gradually | `grass.js` (whitens blade tips toward `vec3(0.95, 0.96, 1.0)`); up-facing terrain whitening (host-game side) |
|
|
50
|
+
| `uDayness` | `1` | 0 night … 1 day | `sky.js` (host-game side) | UNLIT baked-albedo materials (impostors) — dims them so they stop glowing at night |
|
|
51
|
+
| `uSkyCol` | `Color(0x7fb2e5)` | current sky-phase zenith colour | `sky.js` | `water.js` — underwater inscatter tint, sky-reflection gradient, and a luminance clamp so water doesn't glow at dusk/night |
|
|
52
|
+
| `uHorizonCol` | `Color(0xdfe9d8)` | current horizon colour | `sky.js` | `water.js` reflections — water tracks day/dusk/night for free |
|
|
53
|
+
| `uSunDir` | `Vector3(0.3, 0.8, 0.3)` | current key-light direction (sun by day, **moon by night**) | sky/lighting (host-game side) | `grass.js` backlight (`dot(viewDir, uSunDir)^4` rim on blade tips), etc. |
|
|
54
|
+
| `uRain` | `0` | 0 dry … 1 downpour — the rain **falling right now** | Weather system | splash effects (host-game side). See gotcha below — this is deliberately *not* `uWet` |
|
|
55
|
+
| `uRainT` | `0` | seconds, running **while it rains** — the ground's rain clock | Weather system | puddle ripple rings on the ground, so they beat in time with the rain actually falling |
|
|
56
|
+
| `uCloudiness` | `0` | 0 fair … 1 storm deck | `weather.js` — **leads** the rain/snow | sky/cloud rendering, so the sky thickens *before* precipitation and clears after |
|
|
57
|
+
| `uWindDir` | `Vector2(1, 0)` | unit heading the wind blows **toward**, on the ground plane (XZ) | `world/wind.js`, every frame | `grass.js` sway direction + gust-front advection; rain slant (host-game side) |
|
|
58
|
+
| `uWindStr` | `0.4` | 0 calm … ~1 gusting | `world/wind.js`, every frame | `grass.js` gust amplitude; rain slant scaling. CPU consumers (tumbleweeds) read `wind.vx/vz` instead |
|
|
59
|
+
|
|
60
|
+
In this repository the in-engine importers are `wind.js` (writer), `grass.js`, and `water.js`. The Weather/sky/terrain writers and readers named in the source comments live host-game side (the systems ported onto the engine); the whole point of the singleton design is that they wire up to these exact nodes whenever they're built.
|
|
61
|
+
|
|
62
|
+
## Usage example
|
|
63
|
+
|
|
64
|
+
A frame loop driving the wind, a CPU consumer riding it, and a custom TSL material opting into the shared weather:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import * as THREE from 'three/webgpu';
|
|
68
|
+
import { mix, float } from 'three/tsl';
|
|
69
|
+
import { Wind, uWet, uWindDir, uWindStr } from 'sindicate';
|
|
70
|
+
|
|
71
|
+
const wind = new Wind(); // uniforms are already valid after this line
|
|
72
|
+
|
|
73
|
+
// A custom material that darkens when the ground is soaked and shivers with the
|
|
74
|
+
// global gusts — same weather every other material sees, regardless of when
|
|
75
|
+
// this material was built relative to the Weather system.
|
|
76
|
+
const mat = new THREE.MeshStandardNodeMaterial();
|
|
77
|
+
mat.colorNode = baseAlbedo.mul(mix(float(1.0), float(0.7), uWet));
|
|
78
|
+
// ...positionNode can offset along uWindDir scaled by uWindStr for sway...
|
|
79
|
+
|
|
80
|
+
function tick(dt) {
|
|
81
|
+
wind.update(dt); // once per frame, before consumers integrate
|
|
82
|
+
|
|
83
|
+
// CPU consumer: roll a prop downwind
|
|
84
|
+
crate.position.x += wind.vx * dt * 0.2;
|
|
85
|
+
crate.position.z += wind.vz * dt * 0.2;
|
|
86
|
+
|
|
87
|
+
// Host-game weather system easing a shared value (shaders pick it up next draw)
|
|
88
|
+
uWet.value += (stormTargetWet - uWet.value) * Math.min(1, dt * 0.3);
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Gotchas
|
|
93
|
+
|
|
94
|
+
- **Veer, don't circle (bug history).** `Wind.update` sets the heading *directly* as prevailing ± a bounded oscillation (~±27°). The old code *integrated* sines (`angle += … * dt`); the low-frequency term summed to several radians over time and swept the wind all the way around the compass ("pushed things in a 360, weird"). If you touch the heading math, keep it a bounded function of `t`, never an accumulation.
|
|
95
|
+
- **`uRain` is not `uWet`, and the difference is the whole point.** The ground stays dark and slick for a good while after a storm passes (`uWet` eases down slowly), but the moment the rain stops, drops stop landing (`uRain` drops). Splashes read `uRain`; puddles and sheen read `uWet`. Don't collapse them into one value.
|
|
96
|
+
- **Why `uRainT` exists at all.** The Weather system had a `uTime` of its own, but it was private to the rain particles — the ground could not see it, so the puddle rings could not move. `uRainT` is the shared rain clock: it only advances while it rains, so ground ripples beat in time with the falling rain.
|
|
97
|
+
- **`uCloudiness` leads precipitation.** The weather system raises cloudiness *before* rain/snow starts and clears it after they stop, so the sky thickens ahead of a storm. Preserve that lead if you write your own weather driver.
|
|
98
|
+
- **Wind speed is tuned, not physical.** `base = 1.6` m/s is deliberately a light breeze — at higher values the tumbleweeds "rolled too fast". Retune consumers, not the shared wind, if something moves at the wrong speed.
|
|
99
|
+
- **`angle` is the heading the wind blows TOWARD** (radians on the XZ ground plane, `atan2(z, x)`), and `uWindDir` follows the same convention. Note `uWindDir` is a `Vector2` where `.y` holds world **Z** — grass uses `uWindDir.y` for its Z sway.
|
|
100
|
+
- **`uWindStr` publishes the raw gust factor** (`wind.gust`, 0.12–1.05), not `wind.speed`. Shader consumers scale it themselves; CPU consumers wanting real velocity must use `wind.vx/vz`.
|
|
101
|
+
- **Deterministic on purpose.** The wind is layered sines of `t` with no RNG — smooth (no per-frame jerks) and identical after a reload. Don't add randomness to it.
|
|
102
|
+
- **Tick once, early.** Call `wind.update(dt)` exactly once per frame, before anything integrates against `vx/vz`, or CPU and GPU consumers will see different wind for that frame. The constructor publishes too, so uniforms are never stale-default even before the first update.
|
|
103
|
+
- **Ease the weather uniform writes.** `uWet`/`uCover` are meant to be eased toward targets each frame (soak/dry, build/melt), not snapped — materials render whatever `.value` holds, so a snap is a visible pop.
|
|
104
|
+
- **`uSunDir` is the key light, not literally the sun** — it points at the moon at night. Grass backlight and similar effects rely on it always being the current key light.
|
|
105
|
+
- **Water clamps `uSkyCol` luminance** (floor 0.22) so lit water doesn't go pitch black when the sky colour goes near-black at night, and doesn't glow white at dusk — keep sky colours flowing through `uSkyCol`/`uHorizonCol` rather than baking them into water.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Contract: render & scene
|
|
2
|
+
|
|
3
|
+
**Status:** draft (step 3 — extraction from western main.js in progress).
|
|
4
|
+
|
|
5
|
+
## The facet
|
|
6
|
+
|
|
7
|
+
- `engine.render.scene` — attach/detach root for game objects (systems stop touching a raw `game.scene`).
|
|
8
|
+
- `engine.render.camera` — read access. Writing the camera happens ONLY in the camera-overrides phase (time-feel contract §7); anything else is an ordering bug by definition.
|
|
9
|
+
- `engine.render.frustum` — the per-frame culling frustum, built ONCE per unpaused frame after camera-overrides. **May be null; null means "don't cull".** Consumers must treat null as visible — the engine nulls it deliberately when a stale frustum would mis-cull (e.g. a hand-ticked NPC during a paused dialogue whose two-shot faces away from last frame's camera).
|
|
10
|
+
- `engine.render.project(v3, out2)` — world→screen for HUD nameplates/markers (UI never does its own matrix math).
|
|
11
|
+
- `engine.render.post` — the PostProcessing instance; `post.gradeAmount` is the game's one live dial (renderer API doc).
|
|
12
|
+
|
|
13
|
+
## The single render path
|
|
14
|
+
|
|
15
|
+
`renderFrame()` is the ONE call that puts pixels on screen — shared by the boot warm-up and the loop tick. Never add a second render call: every code path that renders must pay the same pipeline state, or the first frame through the "other" path compiles pipelines mid-play.
|
|
16
|
+
|
|
17
|
+
## Exposure write-gating
|
|
18
|
+
|
|
19
|
+
`renderer.toneMappingExposure` (and any renderer-level uniform-adjacent state) is written **only when the value actually changes**. Redundant writes are not free on WebGPU — the gate lives engine-side so games can "set" exposure every frame safely.
|
|
20
|
+
|
|
21
|
+
## Warm-up phases (PERF LAW, mechanical)
|
|
22
|
+
|
|
23
|
+
Boot runs, behind the loading screen, in order:
|
|
24
|
+
1. `warmTextures()` — GPU pre-upload of every cached texture (kills ~75ms first-sight upload hitches).
|
|
25
|
+
2. `warmRenderHidden()` / `warmUpFX()` — force-visible + `compileAsync` every hidden, culled, or count-0 pipeline (pooled VFX, UI-triggered materials) so NO pipeline compiles after boot.
|
|
26
|
+
3. The 360° settle sweep — the camera sweeps a full turn rendering via `renderFrame()`, initializing per-object GPU state along every bearing.
|
|
27
|
+
|
|
28
|
+
A dev-mode watchdog flags any pipeline compile after warm-up completes, with a stack — that's a spawned-uncached asset or a lazily-built material, and it WILL become a multi-second WebGPU freeze on a player's machine.
|
|
29
|
+
|
|
30
|
+
## Freeze profiler
|
|
31
|
+
|
|
32
|
+
The engine stamps phase timings each frame into a ring buffer; a frame over budget dumps the per-phase breakdown (and, with the dev plugin, sinks JSONL to disk). Games get it for free — it exists because "the game hitched once on my machine" is otherwise undebuggable.
|