Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion builder/web/src/lib/trailview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ is the palette of the `world` region.
| `models/` | The house templates: the prototype's buildings as faces whose coordinates name slots that `houses.wgsl` fills per house; the prototype's trees and plants without three.js (`shape.ts`, `trees.ts`, `trees/`, `plant-shape.ts`, `plants/`); the models of flat faces of the kinds and the dressing (`flat.ts`, lifts, landmarks, rocks, animals, crosses) |
| `instances/` | Instances on the drawn surface: producers emit them (`emit.wgsl`), one indirect draw per model and tier (`sort.wgsl`, `draw.wgsl`), a tree's phase models and crown colours, impostors, culling by the last frame's depth (`hiz.wgsl`), point pools of kinds (`points.ts`) |
| `vegetation/` | Each tree's phase on the date (`phenology.ts`); the tree models of the phases, shared by the forest, the shrubs and the plantings (`tree-models.ts`); the canopy tiles about the eye (`canopy.ts`) and the forest placed from them each frame (`forest.wgsl`); the ground plants and shrubs about the eye (`plants.wgsl`); the clearing that moves with the eye (`clearing.wgsl`); the plants of the materials planted in rows, on the rows of the material fields (`rows.wgsl`, `ground/fields.wgsl`) |
| `dressing/` | The herds of the packs on the materials that they graze, and rocks on rough and rocky ground, placed each frame (`dressing.wgsl`) |
| `dressing/` | The herds of the packs on the materials that they graze, and rocks on rough and rocky ground, placed each frame (`dressing.wgsl`); the rocks' density and placement, which the ground also paints past their instances (`rocks.wgsl`) |
| `light/` | The light keys and weather presets, the look at the eye, `shade.wgsl` (toon bands, haze, `finish()`), the sky with the moon, the stars and the Milky Way, and a solar eclipse where the moon covers the sun (`celestial.ts`, `eclipse.ts`, `night.ts`; `tools/trailview/sky.py` makes `milkyway.webp`, `stars.bin` and `moon.webp`), the object shadow maps near and far from the eye (`shadow.ts`) |
| `resolve/` | The render targets, the jitter, TAA and `finish()` |
| `rules/` | The regions of `rules.json`, resolved |
Expand Down
11 changes: 6 additions & 5 deletions builder/web/src/lib/trailview/dressing/dressing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { geometry, type Model } from '../models/flat';
import { rock } from '../models/rocks';
import { bits, named, type Rules } from '../rules/regions';

/** Herds stand within this many metres, rocks within this many; as HERDS and SLABS of dressing.wgsl. */
const HERD_REACH_M = 1500, ROCK_REACH_M = 300, HERDS = 4, SLABS = 3;
/** Units along a herd cell and a rock point's cell, as HERD_SHIFT and ROCK_SHIFT of dressing.wgsl. */
/** Herds stand within this many metres, rocks within this many; as HERDS and SLABS of dressing.wgsl and ROCK_REACH_M of rocks.wgsl. */
const HERD_REACH_M = 1500, ROCK_REACH_M = 1000, HERDS = 4, SLABS = 3;
/** Units along a herd cell and a rock point's cell, as HERD_SHIFT of dressing.wgsl and ROCK_SHIFT of rocks.wgsl. */
const HERD_CELL = 2 ** 14, ROCK_CELL = 2 ** 7;
/** Words of `Herd` and bytes of `Dressing` in dressing.wgsl. */
const HERD_WORDS = 8, DRESSING_BYTES = 48;
Expand All @@ -32,7 +32,8 @@ export class Dressing implements Producer {
constructor(rules: Rules, private readonly instances: Instances) {
instances.produce(this);
const spec = (model: Model, until: number) => ({ tiers: [{ mesh: geometry(model), until }] });
this.rockModel = instances.add(...(['slab', 'boulder'] as const).flatMap(name => Array.from({ length: SLABS }, (_, k) => spec(rock(name, k + 1), ROCK_REACH_M))));
// No fade of the rocks' tier: rocks_of ends each rock at its own distance before the reach.
this.rockModel = instances.add(...(['slab', 'boulder'] as const).flatMap(name => Array.from({ length: SLABS }, (_, k) => ({ ...spec(rock(name, k + 1), ROCK_REACH_M), fade: 0 }))));
const breeds = new Map<string, number>(), used = new Set(rules.regions.flatMap(region => region.herds.map(herd => herd.model)).filter(name => name in BREEDS));
for (const name of used) {
const first = instances.add(...BREEDS[name].coats.map((_, coat) => spec(animal(name, coat), HERD_REACH_M)));
Expand Down Expand Up @@ -80,7 +81,7 @@ export class Dressing implements Producer {
words.set([this.herdSide, this.rockSide], 2);
ints.set([Math.floor(ux / ROCK_CELL) - this.rockSide / 2, Math.floor(uy / ROCK_CELL) - this.rockSide / 2], 4);
words[6] = this.rockModel ?? 0;
floats.set([HERD_REACH_M, ROCK_REACH_M], 8);
floats[8] = HERD_REACH_M;
r.gpu.device.queue.writeBuffer(r.params, 0, params);
}

Expand Down
55 changes: 25 additions & 30 deletions builder/web/src/lib/trailview/dressing/dressing.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
// Herds: one workgroup per herd cell of HERD_SHIFT. A cell holds the groups of each herd of the region
// at its middle, as many per area as the pack's groups per eye's circle of 400 m; a group stands where
// its middle's material is one that the herd grazes, its animals about it on such material too.
// Rocks: one thread per point of a grid of ROCK_SHIFT, jittered: a rock stands on rough or rocky
// ground as the region's outcrops, the slope and the hardness of the rock under it say.
// Rocks: one thread per point of the grid of rocks.wgsl.
// #include ../terrain/drawn-mesh.wgsl
// #include rocks.wgsl
// #include ../ground/look.wgsl
// #include ../instances/emit.wgsl

Expand All @@ -20,9 +20,10 @@ struct Dressing {
// The first of the rock models: SLABS slabs, then as many boulders.
rock_model: u32,
unused: u32,
// Herds stand within x metres, rocks within y.
reach: vec2f,
unused2: vec2f,
// Herds stand within this many metres.
herd_reach: f32,
unused2: f32,
unused3: vec2f,
}

// A herd of a region, as dressing.ts writes it: its first model and coats, its groups per eye's circle,
Expand All @@ -39,20 +40,17 @@ struct Herd {
@group(0) @binding(8) var<uniform> dressing: Dressing;
@group(0) @binding(9) var<storage, read> herds: array<Herd>;

// Units along a herd cell edge: about 420 m at 47° N; and along a rock point's cell: about 3.3 m.
// Units along a herd cell edge: about 420 m at 47° N.
const HERD_SHIFT: u32 = 14u;
const ROCK_SHIFT: u32 = 7u;
// Herds per region, groups of a herd per cell at most, and the area of an eye's circle of 400 m.
const HERDS: u32 = 4u;
const GROUPS: u32 = 3u;
const CIRCLE_M2: f32 = 502655.0;
// A group of 8 spreads over SPREAD_M; nothing stands within CLEAR_M of the eye.
const SPREAD_M: f32 = 14.0;
const CLEAR_M: f32 = 6.0;
// Rocks: their heights, their kinds, and the slope past which no boulder lies.
const ROCK_HEIGHT_M: vec2f = vec2f(0.25, 1.3);
// Rocks: their kinds.
const SLABS: u32 = 3u;
const STEEPEST: f32 = 0.85;
// Things keep this far off a road, a path or a track.
const ROAD_CLEAR_M: f32 = 1.0;

Expand Down Expand Up @@ -84,7 +82,7 @@ fn herds_of(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index
}
let middle = corner + vec2i(vec2f(unit_float(mix_bits(h + 1u)), unit_float(mix_bits(h + 2u))) * f32(size));
let centre = lod_space(vec2f(middle - surface.anchor));
if (length(centre) > dressing.reach.x || !holds(herd.grazes, finest_cell(centre).r)) {
if (length(centre) > dressing.herd_reach || !holds(herd.grazes, finest_cell(centre).r)) {
continue;
}
let count = u32(herd.size.x + unit_float(mix_bits(h + 3u)) * (herd.size.y - herd.size.x + 1.0));
Expand Down Expand Up @@ -115,11 +113,11 @@ fn rocks_of(@builtin(global_invocation_id) id: vec3u) {
return;
}
let point = dressing.rock_origin + vec2i(id.xy);
let h = mix_bits3(point.x, point.y, 77u);
let at = (point << vec2u(ROCK_SHIFT)) + vec2i(vec2f(unit_float(mix_bits(h + 1u)), unit_float(mix_bits(h + 2u))) * f32(1 << ROCK_SHIFT));
let h = rock_hash(point);
let at = rock_at(point, h);
let s = lod_space(vec2f(at - surface.anchor));
let d = length(s);
if (d > dressing.reach.y || d < CLEAR_M) {
if (d > ROCK_REACH_M || d < CLEAR_M) {
return;
}
// The cheap tests first: the view on the level's ground, then the ground's rules, then the slope.
Expand All @@ -128,30 +126,27 @@ fn rocks_of(@builtin(global_invocation_id) id: vec3u) {
return;
}
let cell = cell_at(s, u32(ceil(lod(s))));
let kinds = rule(cell.b, slot_kinds() + i32(cell.r));
let outcrops = rule(cell.b, slot_cliff() + 3).x;
// Rough ground or exposed rock: kinds.a rough, kinds.z rocky.
if ((kinds.a == 0.0 && kinds.z == 0.0) || outcrops <= 0.0) {
return;
}
let hardness = rule(cell.b, slot_hardness() + i32(cell.g)).x;
// As the prototype's near field: hard rock breaks through even on level ground; mapped rock shows as rocks.
let mapped = 0.9 * kinds.z * smoothstep(0.0, 0.6, outcrops) * smoothstep(0.2, 0.6, hardness);
let pick = unit_float(mix_bits(h + 3u)) / 0.55;
if (pick > max(outcrops * (0.3 + 1.4 * hardness), mapped)) {
let pick = rock_pick(h, rock_side(point.y));
if (pick > rock_density(cell, -1.0)) {
return;
}
let n = drawn_normal(s);
let slope = sqrt(max(1.0 - n.z * n.z, 0.0)) / max(n.z, 0.1);
let rocky = max(outcrops * (0.3 + 1.4 * hardness) * clamp(1.4 * slope + 0.6 * (hardness - 0.5) - 0.05, 0.0, 1.0), mapped);
if (slope > STEEPEST || pick > rocky || kept_off(s, ROAD_CLEAR_M)) {
if (pick > rock_density(cell, slope) || kept_off(s, ROAD_CLEAR_M)) {
return;
}
let ground = stand_height(s, at);
if (!known(ground)) {
return;
}
let shape = select(SLABS + mix_bits(h + 4u) % SLABS, mix_bits(h + 5u) % SLABS, unit_float(mix_bits(h + 6u)) < 0.7);
let tall = ROCK_HEIGHT_M.x + pow(unit_float(mix_bits(h + 7u)), 2.0) * (ROCK_HEIGHT_M.y - ROCK_HEIGHT_M.x);
emit(dressing.rock_model + shape, s, ground, tall, 6.2831853 * unit_float(mix_bits(h + 8u)), 1.0, 0.85 + 0.3 * unit_float(mix_bits(h + 9u)), 1.0);
// The distance and the end of the instances' tier (tier_end of emit.wgsl), the same in the eye's
// view and the sun's. A rock under half a pixel tall shows nothing, yet takes a slot of the pool and
// shimmers.
let far = length(render_at(s, ground));
let tall = rock_height(h);
if (far > rock_end(ROCK_REACH_M * params.detail, h) || tall < 0.5 * frame.pixel * far) {
return;
}
let shape = select(SLABS + mix_bits(h + 4u) % SLABS, mix_bits(h + 5u) % SLABS, rock_slab(h));
emit(dressing.rock_model + shape, s, ground, tall, rock_yaw(h), 1.0, 0.85 + 0.3 * unit_float(mix_bits(h + 9u)), 1.0);
}
142 changes: 142 additions & 0 deletions builder/web/src/lib/trailview/dressing/rocks.wgsl
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Strewn rocks, as their instances (rocks_of of dressing.wgsl) and the ground (look.wgsl) share them.
// A rock stands at a point of a jittered grid of ROCK_SHIFT, kept by the density of the ground there.
// Each instance ends at its own distance in the outer ROCK_HANDOFF of the reach, and past it the ground
// paints the rock's footprint: no edge shows where the instances end, and no rock shows twice.
// look.wgsl includes it, and it reads `rule()` of look.wgsl.
// #include ../ground/look.wgsl
// #include ../instances/hash.wgsl

// Units along a rock point's cell: about 3.3 m at 47° N.
const ROCK_SHIFT: u32 = 7u;
// Rock instances stand within ROCK_REACH_M of the eye, as ROCK_REACH_M of dressing.ts, and end over its
// outer ROCK_HANDOFF.
const ROCK_REACH_M: f32 = 1000.0;
const ROCK_HANDOFF: f32 = 0.45;
// The ground in square metres that a rock point stands for at the rules' share 0.55, at every latitude.
const ROCK_POINT_M2: f32 = 17.0;
// The slope (rise per metre) past which no rock lies.
const STEEPEST: f32 = 0.85;
// Rocks are 0.25 to 1.3 m tall, the share u² between. A rock h tall covers an ellipse of half-axes 2.8 h
// and 2.1 h as a slab and a circle of radius 1.1 h as a boulder (models/rocks.ts), 0.7 of them slabs:
// 6.3 m² on average. A rock sinks into a slope and into the coarser far ground, so the ground paints
// PAINTED of a footprint's size: as much as shows of the instances there, by eye.
const ROCK_HEIGHT_M: vec2f = vec2f(0.25, 1.3);
const SLAB_AXES: vec2f = vec2f(2.8, 2.1);
const BOULDER_RADIUS: f32 = 1.1;
const ROCK_AREA_M2: f32 = 6.3;
const PAINTED: f32 = 0.6;
// The rocks' lit tops and their flanks against them (models/rocks.ts), linear; the sky's light on a face
// against the sun's.
const ROCK_ALBEDO: vec3f = vec3f(0.366, 0.371, 0.332);
const FLANK: f32 = 0.65;
const SKY: f32 = 0.35;

// Rocks per square metre on ground of `cell` (r the material, g the lithology, b the region) at `slope`:
// on rough ground or exposed rock as the region's outcrops, the slope and the rock's hardness say. A
// negative slope gives the most that any slope gives.
fn rock_density(cell: vec4u, slope: f32) -> f32 {
// kinds.a rough, kinds.z rocky.
let kinds = rule(cell.b, slot_kinds() + i32(cell.r));
let outcrops = rule(cell.b, slot_cliff() + 3).x;
if ((kinds.a == 0.0 && kinds.z == 0.0) || outcrops <= 0.0 || slope > STEEPEST) {
return 0.0;
}
let hardness = rule(cell.b, slot_hardness() + i32(cell.g)).x;
// As the prototype's near field: hard rock breaks through even on level ground; mapped rock shows as rocks.
let mapped = 0.9 * kinds.z * smoothstep(0.0, 0.6, outcrops) * smoothstep(0.2, 0.6, hardness);
let lie = select(clamp(1.4 * slope + 0.6 * (hardness - 0.5) - 0.05, 0.0, 1.0), 1.0, slope < 0.0);
return max(outcrops * (0.3 + 1.4 * hardness) * lie, mapped) * 0.55 / ROCK_POINT_M2;
}

// A rock point's hash.
fn rock_hash(point: vec2i) -> u32 {
return mix_bits3(point.x, point.y, 77u);
}

// Metres along a rock point's cell in row `y` of points, at the row's own latitude (as texel_metres of
// features.wgsl).
fn rock_side(y: i32) -> f32 {
return 2.0 * 3.14159265 * 6371000.0 / 8388608.0 / cosh(3.14159265 * (1.0 - f32(y) / 4194304.0));
}

// A rock point keeps a rock where this is below the density: a share of 1 over its cell's area, `side`
// metres a side. Where the density times the area passes 1, as a rules' share above ROCK_POINT_M2 /
// (12.5 m² × cos² of the latitude) does, every point keeps one and the ground holds fewer rocks than its
// rules ask.
fn rock_pick(h: u32, side: f32) -> f32 {
return unit_float(mix_bits(h + 3u)) / (side * side);
}

// Where a rock point's rock stands, in world units.
fn rock_at(point: vec2i, h: u32) -> vec2i {
return (point << vec2u(ROCK_SHIFT)) + vec2i(vec2f(unit_float(mix_bits(h + 1u)), unit_float(mix_bits(h + 2u))) * f32(1 << ROCK_SHIFT));
}

// Whether a rock is a slab, else a boulder; its full height in metres; its yaw.
fn rock_slab(h: u32) -> bool {
return unit_float(mix_bits(h + 6u)) < 0.7;
}

fn rock_height(h: u32) -> f32 {
return ROCK_HEIGHT_M.x + pow(unit_float(mix_bits(h + 7u)), 2.0) * (ROCK_HEIGHT_M.y - ROCK_HEIGHT_M.x);
}

fn rock_yaw(h: u32) -> f32 {
return 6.2831853 * unit_float(mix_bits(h + 8u));
}

// Where a rock's instance ends, where the instances end at `end`.
fn rock_end(end: f32, h: u32) -> f32 {
return end * (1.0 - ROCK_HANDOFF * unit_float(mix_bits(h + 10u)));
}

// The share of a pixel at `s`, `far` metres from the eye and `footprint` metres wide, that the ground
// paints as the footprints of the rocks whose instances have ended there, the instances ending at
// `end` (ROCK_REACH_M times the instances' detail). The ground's `cell` and `slope` at `s` stand for
// those of the rocks about it. A footprint is sampled at the pixel's middle with an edge of at most 0.3
// of its size, as the instances are rasterised, so a far field of rocks stays a speckle as the instances
// make it. Pixels 8 to 16 m wide fade to the mean, which wider pixels take.
fn rocks_painted(s: vec2f, cell: vec4u, slope: f32, far: f32, end: f32, footprint: f32) -> f32 {
// The share of the instances that have ended, as rock_end spreads their ends.
let ended = clamp((far / end - (1.0 - ROCK_HANDOFF)) / ROCK_HANDOFF, 0.0, 1.0);
let density = rock_density(cell, slope);
if (density <= 0.0 || ended <= 0.0) {
return 0.0;
}
let mean = min(density * ROCK_AREA_M2 * PAINTED * PAINTED, 1.0) * ended;
if (footprint > 16.0) {
return mean;
}
// The pixel's point cell; a painted rock reaches at most one cell past its own.
let base = surface.anchor >> vec2u(ROCK_SHIFT);
let home = base + vec2i(floor((units_of(s) + vec2f(surface.anchor - (base << vec2u(ROCK_SHIFT)))) / f32(1 << ROCK_SHIFT)));
var painted = 0.0;
for (var row = -1; row <= 1; row++) {
let side = rock_side(home.y + row);
for (var column = -1; column <= 1; column++) {
let point = home + vec2i(column, row);
let h = rock_hash(point);
if (rock_pick(h, side) > density || far <= rock_end(end, h)) {
continue;
}
let d = s - lod_space(vec2f(rock_at(point, h) - surface.anchor));
let yaw = rock_yaw(h);
let along = vec2f(dot(d, vec2f(cos(yaw), sin(yaw))), dot(d, vec2f(-sin(yaw), cos(yaw))));
let axes = min(select(vec2f(BOULDER_RADIUS), SLAB_AXES, rock_slab(h)) * PAINTED * rock_height(h), vec2f(side));
let edge = min(footprint / min(axes.x, axes.y), 0.3);
painted = max(painted, 1.0 - smoothstep(1.0 - edge, 1.0 + edge, length(along / axes)));
}
}
return mix(painted, mean, smoothstep(8.0, 16.0, footprint));
}

// The albedo that a painted rock shows in the light of ground of normal `n`, seen along `view` from the
// eye (render space): its lit top, and its flank toward the eye in its own light. A slab's flank shows
// against its top as its height against its width: 3.4 against 18.5 times the sines of the view.
fn rock_seen(view: vec3f, n: vec3f) -> vec3f {
let s = abs(dot(view, n));
let c = sqrt(max(1.0 - s * s, 0.0));
let flank = normalize(vec3f(-view.xy, 0.0) + vec3f(1e-6, 0.0, 0.0));
let lit = (SKY + max(dot(flank, look.sun.xyz), 0.0)) / (SKY + max(dot(n, look.sun.xyz), 0.0));
return ROCK_ALBEDO * mix(1.0, FLANK * lit, c / (c + 5.4 * s));
}
2 changes: 1 addition & 1 deletion builder/web/src/lib/trailview/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,7 @@ export class TrailView {
camera: { right: right.map(v => v * tx), up: up.map(v => v * ty), forward },
codes: this.groundLook.codes,
debug: DEBUG_VIEWS.indexOf(this.extras.debug), sample,
jitter, scale,
jitter, scale, detail: this.instances.detail,
});
}

Expand Down
Loading
Loading