Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1150c6b
feat(EN-026/027/028/033): particles, decals, animation mixer, bone so…
Jul 12, 2026
04d6bd4
feat(EN-029/031): audio buses + reverb + occlusion low-pass; gamepad …
Jul 12, 2026
b5dc9b1
fix(EN-014): set_user_params silently unbound a material's texture array
Jul 12, 2026
33067c5
docs: file EN-038 (takeScreenshot dead on Windows) + EN-039 (full-tra…
Jul 12, 2026
d1e74e1
feat(EN-025): ragdolls
Jul 12, 2026
176bb83
feat(EN-040): one cloud deck — the sky's clouds and the ground's shadows
Jul 12, 2026
874df88
feat(EN-041): hierarchical foliage wind + EN-042 (shadow budget silen…
Jul 12, 2026
461ad9b
fix(EN-043): a moving caster no longer invalidates the whole static s…
Jul 12, 2026
6f2a5d1
feat(EN-044): depth prepass for cached models — main_hdr 7.4ms -> 2.1ms
Jul 12, 2026
03c72a5
fix(EN-042): the dynamic shadow budget no longer silently drops the p…
Jul 12, 2026
99400fe
fix(EN-045): the static shadow cache only ever worked on a stationary…
Jul 12, 2026
36ffe1e
feat(EN-046): output (swapchain) scale — the knob render_scale could …
Jul 12, 2026
2fee257
docs: reconcile the duplicated EN-042 header (filed open, then fixed …
Jul 12, 2026
59df2f3
fix(EN-047): saveWorld destroyed the world it saved, and reported suc…
Jul 12, 2026
ed232f2
feat(EN-048): launchProcess — Perry's child_process.spawn does nothing
Jul 12, 2026
193c5b3
world: authorable terrain splat (EN-049) + a Perry miscompile (EN-050…
Jul 13, 2026
c1b2980
fix(dx12): compile with DXC so hardware ray query is actually reachable
Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
798 changes: 788 additions & 10 deletions docs/tickets.md

Large diffs are not rendered by default.

122 changes: 122 additions & 0 deletions native/shared/shaders/common/clouds.wgsl
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// One cloud deck — shared by the sky that draws the clouds and the ground that
// takes their shadow.
//
// WHY THIS IS ONE FILE. These used to be two unrelated noise fields that had
// never been reconciled, and the result did not survive looking at:
//
// - The sky's puffs were fBm over a plane pinned to the CAMERA, so they slid
// along with the player instead of hanging over the world.
// - The ground's "cloud shadow" was a *different*, much finer field on a
// different noise, drifting ~80x faster (20 m/s against the sky's 0.25).
//
// So the shadow racing across the grass had no cloud above it, and the cloud
// overhead cast nothing. Worse, only the materials that happened to carry a copy
// of the ground function darkened at all: in the shooter that was the grass, but
// not the terrain under it, nor the trees standing in it — a cloud shadow that
// crosses the field and ignores the forest in the middle of it.
//
// Now there is ONE field, in WORLD space, and the shadow you are standing in
// belongs to the cloud you can look up and see.
//
// THE MODEL. The deck is a horizontal plane at `p.y` metres. A view ray and a
// sun ray are both intersected with it; whatever they hit is the same cloud.
// That is the entire trick, and it is why the apparent size of a cloud in the
// sky and the size of its shadow on the ground are no longer independently
// tunable — they are the same number seen from two directions, which is the
// point.

fn cloud_hash(p: vec2<f32>) -> f32 {
return fract(sin(dot(p, vec2<f32>(127.1, 311.7))) * 43758.5453);
}

fn cloud_noise(p: vec2<f32>) -> f32 {
let i = floor(p);
let f = fract(p);
let uu = f * f * (3.0 - 2.0 * f);
let a = cloud_hash(i);
let b = cloud_hash(i + vec2<f32>(1.0, 0.0));
let c = cloud_hash(i + vec2<f32>(0.0, 1.0));
let d = cloud_hash(i + vec2<f32>(1.0, 1.0));
return mix(mix(a, b, uu.x), mix(c, d, uu.x), uu.y);
}

fn cloud_fbm(p0: vec2<f32>) -> f32 {
var s = 0.0;
var amp = 0.5;
var q = p0;
for (var i = 0; i < 5; i = i + 1) {
s = s + amp * cloud_noise(q);
q = q * 2.03;
amp = amp * 0.5;
}
return s;
}

// Cloud params, passed by each caller from whichever uniform block it happens to
// have (the sky pass and the material ABI do not share one, so this file takes
// them explicitly rather than reaching for a global):
//
// x = shadow strength 0 = the world ignores the clouds entirely (default)
// y = deck height world metres
// z = feature scale noise units per metre — 1/z is the cloud size
// w = drift speed metres/second
//
// The drift DIRECTION is the wind vector — the same one that bends the grass —
// so the clouds travel the way the foliage under them is leaning. A game that
// never set a wind still gets a slow default heading rather than a frozen sky.
fn cloud_drift(wind_xy: vec2<f32>, t: f32, speed: f32) -> vec2<f32> {
var d = wind_xy;
if (dot(d, d) < 1e-6) { d = vec2<f32>(1.0, 0.25); }
return normalize(d) * speed * t;
}

// Coverage of the deck at a point ON the deck. 0 = clear sky, 1 = solid cloud.
fn cloud_density(deck_xz: vec2<f32>, wind_xy: vec2<f32>, t: f32, cp: vec4<f32>) -> f32 {
let p = (deck_xz - cloud_drift(wind_xy, t, cp.w)) * cp.z;
// The threshold is high on purpose: it buys mostly-open sky with the
// occasional real cloud, instead of a permanent grey smear. A low threshold
// here is what makes a procedural deck read as fog.
return smoothstep(0.56, 1.04, cloud_fbm(p));
}

// Where a ray from `origin` along `dir` pierces the deck.
fn cloud_deck_hit(origin: vec3<f32>, dir: vec3<f32>, deck_y: f32) -> vec2<f32> {
let t = (deck_y - origin.y) / dir.y;
return origin.xz + dir.xz * t;
}

// --- the two consumers -------------------------------------------------------

// SKY: coverage along a view ray. Returns (coverage, sun-alignment) — the caller
// colours the cloud from the second component so puffs facing the sun burn white
// and the ones facing away stay cool grey.
fn cloud_cover_view(cam: vec3<f32>, dir: vec3<f32>, sun_dir: vec3<f32>,
wind_xy: vec2<f32>, t: f32, cp: vec4<f32>) -> vec2<f32> {
if (dir.y <= 0.02) { return vec2<f32>(0.0, 0.0); }
var cov = cloud_density(cloud_deck_hit(cam, dir, cp.y), wind_xy, t, cp);
// Toward the horizon a view ray runs so far through the deck that modelling
// it as an infinitely thin plane stops being defensible — fade out instead
// of drawing the smear the maths would give.
let horizon_fade = smoothstep(0.03, 0.24, dir.y);
// Thin the deck right around the sun so the disk still burns through.
let near_sun = smoothstep(0.90, 0.999, dot(dir, sun_dir));
cov = cov * horizon_fade * (1.0 - near_sun * 0.8) * 0.9;
let sun_amt = clamp(dot(dir, sun_dir) * 0.5 + 0.5, 0.0, 1.0);
return vec2<f32>(cov, sun_amt);
}

// GROUND: how much sun a world point keeps. 1.0 = full sun, lower = under cloud.
// Multiply this into direct sunlight only — a cloud blocks the sun, it does not
// stop the sky from being blue, and folding it into ambient too is what makes
// cloud shadows read as flat grey paint rather than as shade.
fn cloud_shadow_at(world_pos: vec3<f32>, sun_dir: vec3<f32>,
wind_xy: vec2<f32>, t: f32, cp: vec4<f32>) -> f32 {
if (cp.x <= 0.0) { return 1.0; }
// Sun on the horizon: the shadow ray runs nearly parallel to the deck and
// the intersection shoots off to infinity, so the noise lookup lands a
// kilometre away and swims. Fade the whole effect out as the sun sets.
let up = sun_dir.y;
if (up <= 0.02) { return 1.0; }
let cov = cloud_density(cloud_deck_hit(world_pos, sun_dir, cp.y), wind_xy, t, cp);
return 1.0 - cov * cp.x * smoothstep(0.02, 0.20, up);
}
98 changes: 98 additions & 0 deletions native/shared/shaders/common/foliage_wind.wgsl
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Hierarchical foliage wind — one field, shared by the scene pass that draws the
// tree and the shadow pass that casts it.
//
// WHY THREE LAYERS. A tree does not move as one thing. The trunk leans slowly
// under the whole wind load, the branches swing at their own rate, and the
// leaves flutter fast at the tips. Driving all of it from a single sine is what
// makes procedural foliage read as rippling cloth instead of wood.
//
// What was here before did less than that: the engine swayed *alpha-cut
// materials only*, so leaf cards fluttered and every trunk in every game was
// perfectly rigid — and the shadow shaders applied no wind at all, so the leaves
// moved while their shadows stayed nailed to the ground.
//
// WHERE THE WEIGHTS COME FROM. SH-013 planned to author them into vertex colours
// (R = bend, G = branch, B = flutter). That is the right answer for hand-modelled
// trees, but ours are procedurally generated, so the regions are known exactly and
// can simply be derived from where the vertex sits relative to the tree's base:
//
// trunk bend proportional to height^2 -- a cantilever: the crown travels, the roots do not
// branch sway proportional to reach out from the trunk axis
// leaf flutter cutout cards only, small and fast
//
// So: no new vertex attribute, no GLB re-bake, no COLOR_0 (which the scene shader
// already spends on albedo tint), and it works on any foliage model the game flags.
//
// The offset is returned in WORLD space and added after the model transform.
// Displacing in local space would let each tree's per-instance yaw rotate the
// wind with it, and a stand of trees would bend in a dozen different directions.

// `rel` is the vertex's WORLD-space offset from its model origin (so it is
// already scaled and rotated -- the coefficients below are in metres and mean the
// same thing for a sapling and a giant).
// `wind` is the global wind vec4: xy = direction in the XZ plane, z = amplitude,
// w = elapsed seconds.
// `amount` scales the whole effect per draw; 0 = this is not foliage, don't move
// it. `is_leaf` is 1.0 for cutout cards, 0.0 for wood.
fn foliage_wind_world(rel: vec3<f32>, model_origin: vec3<f32>,
wind: vec4<f32>, amount: f32, is_leaf: f32) -> vec3<f32> {
if (amount <= 0.0 || wind.z <= 0.0) { return vec3<f32>(0.0, 0.0, 0.0); }

let dir = vec3<f32>(wind.x, 0.0, wind.y);
let amp = wind.z * amount;
let t = wind.w;

// Per-tree phase from the model's world origin. Without it a stand of trees
// sways in lockstep, which is the single most obvious tell of fake foliage.
let ph = model_origin.x * 0.137 + model_origin.z * 0.241;

let h = max(rel.y, 0.0);
let reach = length(rel.xz);

// 1. TRUNK BEND — cantilever, so travel grows with the SQUARE of height: the
// base is planted, the crown swings. Slow (~0.15 Hz). This is the motion
// you read from 30 m away, and it is the one that did not exist at all.
// Coefficients are tuned so amount = 1.0 is a real tree in a lazy breeze:
// with the shooter's wind amplitude (0.10) a 4 m crown travels ~35 cm.
let bend = amp * 0.22 * h * h * sin(t * 0.95 + ph);

// 2. BRANCH SWAY — how far the vertex reaches out from the trunk axis. Medium
// (~0.4 Hz), phase-offset by azimuth so opposite limbs do not swing together.
let azim = atan2(rel.z, rel.x);
// Gated by height as well as reach: without it the flare at the base of the
// trunk has some reach and would shuffle its own roots.
let swing = amp * 0.70 * reach * clamp(h * 0.5, 0.0, 1.0)
* sin(t * 2.4 + ph + azim * 1.7);

// 3. LEAF FLUTTER — cutout cards only. Fast (~1 Hz), small, and keyed on the
// vertex's own position so neighbouring cards break up rather than shimmer
// as a sheet.
let fl = amp * 0.60 * is_leaf * sin(t * 6.0 + ph + h * 3.1 + reach * 5.0);

var o = dir * (bend + swing + fl);
// Leaves twist rather than only sliding downwind; and a leaning crown dips a
// little, because the tip is swinging on an arc, not a rail.
o.y = o.y + fl * 0.45 - bend * 0.15;
return o;
}

// Convenience wrapper: local-space vertex in, wind-displaced local-space vertex
// out. Both the scene pass and the shadow pass want exactly this, and they must
// agree exactly or a tree detaches from its own shadow.
//
// The offset is computed in WORLD space (see above) and then brought back into
// local space by inverting the model's linear part. That inverse is exact for the
// transforms these draws use -- rotation plus UNIFORM scale -- because then
// M^-1 = M^T / s^2.
fn foliage_wind_local(local_pos: vec3<f32>, model: mat4x4<f32>,
wind: vec4<f32>, amount: f32, is_leaf: f32) -> vec3<f32> {
if (amount <= 0.0 || wind.z <= 0.0) { return local_pos; }
let origin = model[3].xyz;
let rel = (model * vec4<f32>(local_pos, 1.0)).xyz - origin;
let wo = foliage_wind_world(rel, origin, wind, amount, is_leaf);
let c0 = model[0].xyz;
let s2 = max(dot(c0, c0), 1e-8);
return local_pos + vec3<f32>(dot(model[0].xyz, wo),
dot(model[1].xyz, wo),
dot(model[2].xyz, wo)) / s2;
}
7 changes: 7 additions & 0 deletions native/shared/shaders/material_abi.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ struct PerFrame {
// (~0.1 m typical for grass); w = frequency in Hz (~1.0 typical).
// Foliage / cloth materials sample this in their vertex stage.
wind: vec4<f32>,

// Cloud deck. x = shadow strength (0 = the world ignores the clouds),
// y = deck height in metres, z = feature scale, w = drift speed in m/s.
// Feed it to `cloud_shadow_at` from common/clouds.wgsl and multiply the
// result into DIRECT sunlight only — this is the same deck the sky pass
// draws, so a shadow here has a cloud above it.
cloud: vec4<f32>,
};

@group(0) @binding(0) var<uniform> frame: PerFrame;
Expand Down
Loading
Loading