3d-experiencesNelsonPKlarkExpériences visuelles & sonores

Derrière l’image.

Les programmes que la carte graphique exécute à chaque image,
lus tels quels dans le dépôt au moment de la publication.

shaders
73
lignes
7 797
passes compute
12
scènes
11
prototype
1

Matières

Ce qui donne leur matière aux surfaces, rendu sur une sphère avec le code même des scènes. La mer garde son cadre. Les sphères tournent lentement ; la lumière reste.

Ces aperçus demandent WebGPU, que ce navigateur n’offre pas.

Après la pluie

1 matière · common.wgsl

Les néons de la ruelle et les ronds de la pluie, appelés comme le sol les appelle. L’asphalte photographié et le reflet de la rue sont retirés : la sphère est un bitume sombre, et l’averse en ride la normale.

Compilation des shaders de la scène…Cet aperçu n’a pas pu démarrer sur ce navigateur.

Asphalte mouilléilluminationLes néons sur un bitume sombre. L’averse y ajoute les ronds de la pluie et un brillant plus serré.
  • pluie.wgslfragment Wet asphalt for /shaders: the alley's neon (illumination) and the rain's rings (waves), lifted from the scene. The photographed asphalt and the reflection of the street are not here: the ball is a dark bitumen whose normal the rings disturb, the way the ground adds them. 74 lignes
    Fichier
    src/materials/pluie.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    1 liaison
    // Wet asphalt for /shaders: the alley's neon (illumination) and the rain's rings (waves), lifted from
    // the scene. The photographed asphalt and the reflection of the street are not here: the ball is a
    // dark bitumen whose normal the rings disturb, the way the ground adds them.
    
    struct Balls {
      tiles: array<vec4f, 8>,
      kinds: array<vec4f, 8>, // x: material, y: radius (m), z: turn (rad)
      view: vec4f,            // x: tile count, y: tan(half field of view), z: camera distance (radii), w: corner radius (px)
    };
    @group(0) @binding(11) var<uniform> balls: Balls;
    
    fn turnY(v: vec3f, angle: f32) -> vec3f {
      let c = cos(angle);
      let s = sin(angle);
      return vec3f(c * v.x + s * v.z, v.y, -s * v.x + c * v.z);
    }
    
    /** Bitumen under the alley's lights. `scene.controls.z` is the rain, as the ground multiplies the rings by it. */
    fn shadeBall(p: vec3f, n: vec3f, q: vec3f) -> vec3f {
      let rain = scene.controls.z;
      let ripple = waves(q.xz) * rain;
      let east = normalize(cross(vec3f(0.0, 1.0, 0.0), n) + vec3f(1e-4, 0.0, 0.0));
      let north = cross(n, east);
      let rippled = normalize(n + east * ripple.x + north * ripple.y);
      let base = vec3f(0.045, 0.047, 0.05);
      let rough = mix(0.62, 0.28, rain);
      return fog(illumination(p, rippled, base, rough), p);
    }
    
    fn backdrop(rd: vec3f) -> vec3f {
      return vec3f(0.012, 0.02, 0.032) * (0.35 + 0.65 * max(rd.y, 0.0));
    }
    
    /** The scene's grade (present.wgsl) without the vignette or the film grain. */
    fn grade(color: vec3f) -> vec3f {
      var c = aces(color);
      c = pow(c, vec3f(1.0 / 2.2));
      return mix(c, c * c * (3.0 - 2.0 * c), 0.2);
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      var index = -1;
      for (var i = 0; i < i32(balls.view.x); i++) {
        let r = balls.tiles[i];
        if (all(position.xy >= r.xy) && all(position.xy < r.xy + r.zw)) { index = i; }
      }
      if (index < 0) { return vec4f(0.0); }
      let tile = balls.tiles[index];
      let kind = balls.kinds[index];
      let radius = kind.y;
      let ndc = ((position.xy - tile.xy) / tile.zw * 2.0 - 1.0) * vec2f(1.0, -1.0);
      let eye = normalize(vec3f(0.0, 0.2, 1.0)) * radius * balls.view.z;
      let forward = normalize(-eye);
      let right = normalize(cross(forward, vec3f(0.0, 1.0, 0.0)));
      let up = cross(right, forward);
      let rd = normalize(forward + (right * ndc.x + up * ndc.y) * balls.view.y);
    
      var color = backdrop(rd);
      let along = -dot(eye, rd);
      let miss = length(eye + rd * along);
      let pixel = length(eye) * 2.0 * balls.view.y / tile.w;
      let coverage = clamp((radius - miss) / pixel + 0.5, 0.0, 1.0);
      if (coverage > 0.0) {
        let t = along - sqrt(max(radius * radius - miss * miss, 0.0));
        let p = eye + rd * t;
        // Waves are sampled on the turning ball; the lights stay in the world, on the geometric normal.
        color = mix(color, shadeBall(p, normalize(p), turnY(p, kind.z)), coverage);
      }
      let halfSize = tile.zw * 0.5;
      let corner = balls.view.w;
      let d = length(max(abs(position.xy - tile.xy - halfSize) - halfSize + corner, vec2f(0.0))) - corner;
      let alpha = clamp(0.5 - d, 0.0, 1.0);
      return vec4f(grade(color) * alpha, alpha);
    }

Soundwave

1 matière · soundwave.wgsl

La couleur d’une particule, telle que le fragment de la scène la calcule, pour un passage soutenu. Chaque lumière est une des palettes : la sphère est le disque de cette particule.

Compilation des shaders de la scène…Cet aperçu n’a pas pu démarrer sur ce navigateur.

Impulsionfragment_mainLe disque d’une particule : sa couleur, son halo, et le brouillard qui commence loin derrière.
  • soundwave.wgslfragment One particle of /soundwave, lifted as the fragment shades it. The ball is that particle's disc: the tile is mapped onto the quad, so the soft circle is the sphere. 47 lignes
    Fichier
    src/materials/soundwave.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    1 liaison
    // One particle of /soundwave, lifted as the fragment shades it. The ball is that particle's disc:
    // the tile is mapped onto the quad, so the soft circle is the sphere.
    
    struct Balls {
      tiles: array<vec4f, 8>,
      kinds: array<vec4f, 8>,
      view: vec4f,
    };
    @group(0) @binding(11) var<uniform> balls: Balls;
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      var index = -1;
      for (var i = 0; i < i32(balls.view.x); i++) {
        let r = balls.tiles[i];
        if (all(position.xy >= r.xy) && all(position.xy < r.xy + r.zw)) { index = i; }
      }
      if (index < 0) { return vec4f(0.0); }
      let tile = balls.tiles[index];
      let kind = balls.kinds[index];
      let radius = kind.y;
      let ndc = ((position.xy - tile.xy) / tile.zw * 2.0 - 1.0) * vec2f(1.0, -1.0);
      let eye = normalize(vec3f(0.0, 0.2, 1.0)) * radius * balls.view.z;
      let forward = normalize(-eye);
      let right = normalize(cross(forward, vec3f(0.0, 1.0, 0.0)));
      let up = cross(right, forward);
      let rd = normalize(forward + (right * ndc.x + up * ndc.y) * balls.view.y);
    
      var color = vec3f(0.0);
      let along = -dot(eye, rd);
      let miss = length(eye + rd * along);
      let pixel = length(eye) * 2.0 * balls.view.y / tile.w;
      let coverage = clamp((radius - miss) / pixel + 0.5, 0.0, 1.0);
      if (coverage > 0.0) {
        // Centre of the particle quad, depth well inside the fog. The disc's falloff is the ball's shade.
        var input: VertexOutput;
        input.clip = vec4f(ndc, 0.0, 1.0);
        input.uv = vec2f(0.5) + ndc * 0.55;
        input.depth = 8.0;
        color = fragment_main(input).rgb;
      }
      let halfSize = tile.zw * 0.5;
      let corner = balls.view.w;
      let d = length(max(abs(position.xy - tile.xy - halfSize) - halfSize + corner, vec2f(0.0))) - corner;
      let alpha = clamp(0.5 - d, 0.0, 1.0) * coverage;
      let shown = clamp(color, vec3f(0.0), vec3f(1.0));
      return vec4f(shown * alpha, alpha);
    }

Le dernier souffle

2 matières · meadow.wgsl

La lumière du jour et de la nuit, celle qui teinte le premier plan. La photo du paysage n’est pas là : derrière, les nuages et les étoiles de la scène.

Compilation des shaders de la scène…Cet aperçu n’a pas pu démarrer sur ce navigateur.

PétalelightUn rouge de coquelicot, passé dans la même lumière que le premier plan.
PrairielightLe vert de l’herbe, sous cette même lumière.
  • fleur.wgslfragment Petal and meadow for /shaders: light() is the scene's grade, called on two albedos. The photograph of the landscape is not sampled. Behind the balls, the scene's own clouds and stars. 71 lignes
    Fichier
    src/materials/fleur.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    1 liaison
    // Petal and meadow for /shaders: light() is the scene's grade, called on two albedos. The photograph
    // of the landscape is not sampled. Behind the balls, the scene's own clouds and stars.
    
    struct Balls {
      tiles: array<vec4f, 8>,
      kinds: array<vec4f, 8>,
      view: vec4f,
    };
    @group(0) @binding(11) var<uniform> balls: Balls;
    
    fn shadeBall(material: u32) -> vec3f {
      switch (material) {
        case 0u: { return light(vec3f(0.72, 0.08, 0.06), 1.0); }
        case 1u: { return light(vec3f(0.15, 0.32, 0.08), 1.0); }
        default: { return vec3f(0.0); }
      }
    }
    
    /** Sky after the photograph, the way backgroundFragment builds it: graded base, clouds, then stars. */
    fn backdrop(rd: vec3f, pixel: vec2f) -> vec3f {
      let night = max(-scene.mood.x, 0.0);
      let zenith = mix(vec3f(0.55, 0.68, 0.82), vec3f(0.02, 0.03, 0.07), night);
      let horizon = mix(vec3f(0.78, 0.74, 0.64), vec3f(0.05, 0.045, 0.07), night);
      var base = light(mix(horizon, zenith, smoothstep(-0.05, 0.65, rd.y)), 0.0);
      if (rd.y <= 0.02) { return base; }
      let position = rd.xz / max(rd.y, 0.08) * vec2f(0.42, 0.7);
      let near = cloudMass(position - vec2f(scene.mood.w * 0.02, 0.0) + vec2f(3.1, 7.4));
      let far = cloudNoise(position * 0.65 - vec2f(scene.mood.w * 0.008, 0.0) + vec2f(17.3, 4.9));
      let billows = smoothstep(0.42, 0.72, near);
      let veil = smoothstep(0.45, 0.78, far);
      let silver = light(vec3f(0.59, 0.64, 0.68), 0.0);
      let shadow = light(vec3f(0.25, 0.31, 0.38), 0.0);
      var color = mix(base, shadow, veil * 0.35);
      color = mix(color, silver, billows * 0.7);
      let stars = smoothstep(0.12, 0.85, night) * (1.0 - smoothstep(0.22, 0.88, billows));
      if (stars > 0.0) { color += starlight(pixel) * stars; }
      return color;
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      var index = -1;
      for (var i = 0; i < i32(balls.view.x); i++) {
        let r = balls.tiles[i];
        if (all(position.xy >= r.xy) && all(position.xy < r.xy + r.zw)) { index = i; }
      }
      if (index < 0) { return vec4f(0.0); }
      let tile = balls.tiles[index];
      let kind = balls.kinds[index];
      let radius = kind.y;
      let ndc = ((position.xy - tile.xy) / tile.zw * 2.0 - 1.0) * vec2f(1.0, -1.0);
      let eye = normalize(vec3f(0.0, 0.2, 1.0)) * radius * balls.view.z;
      let forward = normalize(-eye);
      let right = normalize(cross(forward, vec3f(0.0, 1.0, 0.0)));
      let up = cross(right, forward);
      let rd = normalize(forward + (right * ndc.x + up * ndc.y) * balls.view.y);
    
      var color = backdrop(rd, position.xy);
      let along = -dot(eye, rd);
      let miss = length(eye + rd * along);
      let pixel = length(eye) * 2.0 * balls.view.y / tile.w;
      let coverage = clamp((radius - miss) / pixel + 0.5, 0.0, 1.0);
      if (coverage > 0.0) {
        color = mix(color, shadeBall(u32(kind.x)), coverage);
      }
      let halfSize = tile.zw * 0.5;
      let corner = balls.view.w;
      let d = length(max(abs(position.xy - tile.xy - halfSize) - halfSize + corner, vec2f(0.0))) - corner;
      let alpha = clamp(0.5 - d, 0.0, 1.0);
      // light() is already the colour the scene shows.
      return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)) * alpha, alpha);
    }

The sea remembers

1 matière · water.frag.glsl

Le fragment de la mer, compilé tel quel. Seule la version du langage change, pour WebGL2. Les textures de sable, de nuages et de vie sont des aplats : la houle, le ciel et le reflet restent ceux du fichier.

Compilation des shaders de la scène…Cet aperçu n’a pas pu démarrer sur ce navigateur.

La mermainLa mer vue du rivage : ciel, houle et reflet, sous le jour, le crépuscule ou la nuit de la scène.

La nuée

1 matière · common.wgsl

Le ciel du soir, ses nuages et ses étoiles, sous les lumières de la scène. La sphère montre ce ciel dans la direction de sa normale ; derrière, le même ciel.

Compilation des shaders de la scène…Cet aperçu n’a pas pu démarrer sur ce navigateur.

Ciel du soirskyColorLe dégradé du ciel, le soleil, les altocumulus et les premières étoiles.
  • nuee.wgslfragment The dusk sky of /nuee on a sphere: skyColor, the cloud deck and the stars, in the direction of the normal. display() is already what the canvas shows, so nothing grades it a second time. 53 lignes
    Fichier
    src/materials/nuee.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    1 liaison
    // The dusk sky of /nuee on a sphere: skyColor, the cloud deck and the stars, in the direction of the
    // normal. display() is already what the canvas shows, so nothing grades it a second time.
    
    struct Balls {
      tiles: array<vec4f, 8>,
      kinds: array<vec4f, 8>,
      view: vec4f,
    };
    @group(0) @binding(11) var<uniform> balls: Balls;
    
    fn turnY(v: vec3f, angle: f32) -> vec3f {
      let c = cos(angle);
      let s = sin(angle);
      return vec3f(c * v.x + s * v.z, v.y, -s * v.x + c * v.z);
    }
    
    fn look(dir: vec3f) -> vec3f {
      let d = normalize(dir);
      return clouds(d, skyColor(d)) + stars(d);
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      var index = -1;
      for (var i = 0; i < i32(balls.view.x); i++) {
        let r = balls.tiles[i];
        if (all(position.xy >= r.xy) && all(position.xy < r.xy + r.zw)) { index = i; }
      }
      if (index < 0) { return vec4f(0.0); }
      let tile = balls.tiles[index];
      let kind = balls.kinds[index];
      let radius = kind.y;
      let ndc = ((position.xy - tile.xy) / tile.zw * 2.0 - 1.0) * vec2f(1.0, -1.0);
      let eye = normalize(vec3f(0.0, 0.2, 1.0)) * radius * balls.view.z;
      let forward = normalize(-eye);
      let right = normalize(cross(forward, vec3f(0.0, 1.0, 0.0)));
      let up = cross(right, forward);
      let rd = normalize(forward + (right * ndc.x + up * ndc.y) * balls.view.y);
    
      var color = look(rd);
      let along = -dot(eye, rd);
      let miss = length(eye + rd * along);
      let pixel = length(eye) * 2.0 * balls.view.y / tile.w;
      let coverage = clamp((radius - miss) / pixel + 0.5, 0.0, 1.0);
      if (coverage > 0.0) {
        let p = eye + rd * (along - sqrt(max(radius * radius - miss * miss, 0.0)));
        color = mix(color, look(normalize(turnY(p, kind.z))), coverage);
      }
      let halfSize = tile.zw * 0.5;
      let corner = balls.view.w;
      let d = length(max(abs(position.xy - tile.xy - halfSize) - halfSize + corner, vec2f(0.0))) - corner;
      let alpha = clamp(0.5 - d, 0.0, 1.0);
      return vec4f(display(color) * alpha, alpha);
    }

Ce qui flotte

2 matières · water.wgsl, sprites.wgsl

L’eau libre et la chair translucide, aux profondeurs de la scène. La caustique photographiée est remplacée par une lumière uniforme, pour ne pas emporter sa texture.

Compilation des shaders de la scène…Cet aperçu n’a pas pu démarrer sur ce navigateur.

EauopenWaterL’eau ouverte : claire vers la surface, sombre vers le fond, avec le halo du soleil.
ClochetranslucentUn tissu pâle, éclairé par le haut et plus clair sur la tranche.
  • meduse.wgslfragment Open water and the bell's tissue for /shaders. causticAt is replaced so the caustic texture stays out: the tissue is lit by an even sun. The grade is the scene's, without bloom, vignette or dither. 64 lignes
    Fichier
    src/materials/meduse.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    1 liaison
    // Open water and the bell's tissue for /shaders. causticAt is replaced so the caustic texture stays
    // out: the tissue is lit by an even sun. The grade is the scene's, without bloom, vignette or dither.
    
    struct Balls {
      tiles: array<vec4f, 8>,
      kinds: array<vec4f, 8>,
      view: vec4f,
    };
    @group(0) @binding(11) var<uniform> balls: Balls;
    
    /** No photographed caustic: sunlight arrives even, which is what the texture mixes toward. */
    fn causticAt(p: vec3f) -> vec3f { return vec3f(1.0); }
    
    fn turnY(v: vec3f, angle: f32) -> vec3f {
      let c = cos(angle);
      let s = sin(angle);
      return vec3f(c * v.x + s * v.z, v.y, -s * v.x + c * v.z);
    }
    
    fn shadeBall(material: u32, p: vec3f, n: vec3f, turn: f32) -> vec3f {
      switch (material) {
        // The water colour depends on direction: turning the normal shows surface, horizon and abyss.
        case 0u: { return openWater(turnY(n, turn)); }
        // Pale tissue. The scene multiplies the albedo; the rim brightens where the surface is edge-on.
        case 1u: { return translucent(p, n, vec3f(0.72, 0.8, 0.76), 1.0); }
        default: { return vec3f(0.0); }
      }
    }
    
    fn grade(color: vec3f) -> vec3f { return toSrgb(aces(color * scene.flow.z)); }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      var index = -1;
      for (var i = 0; i < i32(balls.view.x); i++) {
        let r = balls.tiles[i];
        if (all(position.xy >= r.xy) && all(position.xy < r.xy + r.zw)) { index = i; }
      }
      if (index < 0) { return vec4f(0.0); }
      let tile = balls.tiles[index];
      let kind = balls.kinds[index];
      let radius = kind.y;
      let ndc = ((position.xy - tile.xy) / tile.zw * 2.0 - 1.0) * vec2f(1.0, -1.0);
      let eye = normalize(vec3f(0.0, 0.2, 1.0)) * radius * balls.view.z;
      let forward = normalize(-eye);
      let right = normalize(cross(forward, vec3f(0.0, 1.0, 0.0)));
      let up = cross(right, forward);
      let rd = normalize(forward + (right * ndc.x + up * ndc.y) * balls.view.y);
    
      var color = openWater(rd);
      let along = -dot(eye, rd);
      let miss = length(eye + rd * along);
      let pixel = length(eye) * 2.0 * balls.view.y / tile.w;
      let coverage = clamp((radius - miss) / pixel + 0.5, 0.0, 1.0);
      if (coverage > 0.0) {
        let t = along - sqrt(max(radius * radius - miss * miss, 0.0));
        let p = eye + rd * t;
        color = mix(color, shadeBall(u32(kind.x), p, normalize(p), kind.z), coverage);
      }
      let halfSize = tile.zw * 0.5;
      let corner = balls.view.w;
      let d = length(max(abs(position.xy - tile.xy - halfSize) - halfSize + corner, vec2f(0.0))) - corner;
      let alpha = clamp(0.5 - d, 0.0, 1.0);
      return vec4f(grade(color) * alpha, alpha);
    }

Ce que dit le vent

7 matières · scene.wgsl, lantern.wgsl

Les fonctions de la scène, appelées comme elle les appelle, sous sa lumière et dans son jardin. Seul ce qui tient au carillon autour d’une surface est retiré : les ombres et les reflets des autres tubes.

Compilation des shaders de la scène…Cet aperçu n’a pas pu démarrer sur ce navigateur.

Aluminium brosséshadeTubeLes six tubes : GGX anisotrope, lisse dans la longueur et rugueux autour, et le jardin en reflet.
Bois de la plaqueshadeWoodCernes et fibres, un vernis mat qui ne brille qu’en lumière rasante.
Bois de la voileshadeWoodUn bois plus clair, veiné en long, celui que le vent pousse.
Écorce du pommiershadeBarkCrêtes sombres et taches de lichen, un liseré de soleil sur le bord.
FeuilleshadeLeafNervures, et la lumière qui traverse le limbe à contre-jour.
PommeshadeAppleJaune d’un côté, rougie de l’autre, un brillant doux.
Granit de la lanterneshadeLanternPierre grenue, mousse sur ce qui regarde le ciel.
  • carillon.wgslfragment Material balls for /shaders: the /carillon shading functions, lifted from its files with everything they use (src/materials/wgsl.ts), called as the scene calls them, on a sphere standing where the chime hangs. The scene's light and garden come with them; only what belongs to the chime around a surface is replaced below. One pass draws every tile of the grid: each finds its own rectangle. 97 lignes
    Fichier
    src/materials/carillon.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    1 liaison
    // Material balls for /shaders: the /carillon shading functions, lifted from its files with everything they
    // use (src/materials/wgsl.ts), called as the scene calls them, on a sphere standing where the chime hangs.
    // The scene's light and garden come with them; only what belongs to the chime around a surface is
    // replaced below. One pass draws every tile of the grid: each finds its own rectangle.
    
    struct Balls {
      tiles: array<vec4f, 8>, // x, y, width, height of each tile (device px)
      kinds: array<vec4f, 8>, // x: material (catalogue order), y: radius (m), z: turn (rad)
      view: vec4f,            // x: tile count, y: tan(half field of view), z: camera distance (radii), w: corner radius (px)
    };
    @group(0) @binding(11) var<uniform> balls: Balls;
    
    // ---------------------------------------------------------------- stand-ins for the chime
    
    /** One tube, alone and upright: the brushing runs along its length. */
    fn tubeAxis(i: u32) -> vec4f { return vec4f(0.0, 1.0, 0.0, 0.019); }
    /** No neighbouring tube to darken it. */
    fn tubeOcclusion(p: vec3f, n: vec3f, skip: u32) -> f32 { return 1.0; }
    /** Nothing between the ball and the sun or the moon. */
    fn softShadow(p: vec3f, l: vec3f, skip: u32) -> f32 { return 1.0; }
    /** No other tube to reflect: what the scene shows when a reflected ray misses the chime. */
    fn reflection(p: vec3f, r: vec3f, self_: u32) -> vec3f { return gardenAt(r, false, false); }
    
    // ---------------------------------------------------------------- the ball
    
    fn turnY(v: vec3f, angle: f32) -> vec3f {
      let c = cos(angle);
      let s = sin(angle);
      return vec3f(c * v.x + s * v.z, v.y, -s * v.x + c * v.z);
    }
    
    /** The scene's shading for one material, with the local coordinates its own tracing would give. */
    fn shadeBall(material: u32, t: f32, p: vec3f, n: vec3f, q: vec3f, radius: f32, v: vec3f) -> vec3f {
      switch (material) {
        // A tube's local x runs along its axis.
        case 0u: { return shadeTube(Hit(t, n, M_TUBE, 0u, vec3f(q.y, 0.0, 0.0)), p, v); }
        case 1u: { return shadeWood(Hit(t, n, M_PLATE, PLATE, q), p, v); }
        case 2u: { return shadeWood(Hit(t, n, M_SAIL, SAIL, q), p, v); }
        case 3u: { return shadeBark(Hit(t, n, M_BARK, 0u, q + vec3f(0.3, 0.1, 0.0)), v); }
        // A leaf's local x runs from stalk to tip (0..1), y across its width (-1..1): the midrib faces the viewer.
        case 4u: { return shadeLeaf(Hit(t, n, M_LEAF, 3u, vec3f(q.y / radius * 0.5 + 0.5, q.x / radius, 0.0)), p, v); }
        case 5u: { return shadeApple(Hit(t, n, M_APPLE, 0u, q), p, v); }
        // The lantern's stone at the height of its shaft, below the light box.
        case 6u: { return shadeLantern(q + vec3f(0.0, 0.4, 0.0), n); }
        default: { return vec3f(0.0); }
      }
    }
    
    /** The scene's grade (src/carillon/present.wgsl), without its lens effects: exposure, ACES, a little colour, an S-curve. */
    fn grade(color: vec3f) -> vec3f {
      var mapped = aces(color * scene.zenith.w);
      let luma = dot(mapped, vec3f(0.2126, 0.7152, 0.0722));
      mapped = max(mix(vec3f(luma), mapped, 1.12), vec3f(0.0));
      let display = toSrgb(clamp(mapped, vec3f(0.0), vec3f(1.0)));
      return mix(display, display * display * (3.0 - 2.0 * display), 0.32);
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      var index = -1;
      for (var i = 0; i < i32(balls.view.x); i++) {
        let r = balls.tiles[i];
        if (all(position.xy >= r.xy) && all(position.xy < r.xy + r.zw)) { index = i; }
      }
      if (index < 0) { return vec4f(0.0); }
      let tile = balls.tiles[index];
      let kind = balls.kinds[index];
      let radius = kind.y;
      let ndc = ((position.xy - tile.xy) / tile.zw * 2.0 - 1.0) * vec2f(1.0, -1.0);
      // A camera a little above the ball, looking toward -z like the scene's.
      let eye = normalize(vec3f(0.0, 0.2, 1.0)) * radius * balls.view.z;
      let forward = normalize(-eye);
      let right = normalize(cross(forward, vec3f(0.0, 1.0, 0.0)));
      let up = cross(right, forward);
      let rd = normalize(forward + (right * ndc.x + up * ndc.y) * balls.view.y);
      pixelUV = uv;
      pixelFootprint = 4.0 * balls.view.y / tile.w;
    
      var color = garden(rd, true);
      // Closest approach of the ray to the centre: an edge a pixel wide, antialiased.
      let along = -dot(eye, rd);
      let miss = length(eye + rd * along);
      let pixel = length(eye) * 2.0 * balls.view.y / tile.w;
      let coverage = clamp((radius - miss) / pixel + 0.5, 0.0, 1.0);
      if (coverage > 0.0) {
        let t = along - sqrt(max(radius * radius - miss * miss, 0.0));
        let p = eye + rd * t;
        let n = normalize(p);
        let q = turnY(p, kind.z);
        color = mix(color, shadeBall(u32(kind.x), t, p, n, q, radius, -rd), coverage);
      }
      // Rounded corners, antialiased too.
      let halfSize = tile.zw * 0.5;
      let corner = balls.view.w;
      let d = length(max(abs(position.xy - tile.xy - halfSize) - halfSize + corner, vec2f(0.0))) - corner;
      let alpha = clamp(0.5 - d, 0.0, 1.0);
      return vec4f(grade(color) * alpha, alpha);
    }

À tout vent

2 matières · common.wgsl

La lumière du matin sur une fibre et sur une tige, avec l’ombre de l’horloge de graines. Les dimensions du capitule sont celles du rendu. Derrière, le ciel et la prairie que la scène reflète.

Compilation des shaders de la scène…Cet aperçu n’a pas pu démarrer sur ce navigateur.

AigrettefiberLightUn filament de pappus : reflet faible, transmission vers le soleil, rosée qui éteint le brillant.
TigeplantLightUne surface végétale verte : diffus, lumière traversante, et le bord vers le soleil.
  • pissenlit.wgslfragment A pappus filament and a plant surface for /shaders, lit by fiberLight and plantLight. The clock's dimensions are prepended, as the scene's renderer does, so the seed head still casts its shadow. Behind the balls: the sky and the meadow the scene reflects. 68 lignes
    Fichier
    src/materials/pissenlit.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    1 liaison
    // A pappus filament and a plant surface for /shaders, lit by fiberLight and plantLight. The clock's
    // dimensions are prepended, as the scene's renderer does, so the seed head still casts its shadow.
    // Behind the balls: the sky and the meadow the scene reflects.
    
    struct Balls {
      tiles: array<vec4f, 8>,
      kinds: array<vec4f, 8>,
      view: vec4f,
    };
    @group(0) @binding(11) var<uniform> balls: Balls;
    
    fn turnY(v: vec3f, angle: f32) -> vec3f {
      let c = cos(angle);
      let s = sin(angle);
      return vec3f(c * v.x + s * v.z, v.y, -s * v.x + c * v.z);
    }
    
    fn shadeBall(material: u32, p: vec3f, n: vec3f, v: vec3f) -> vec3f {
      switch (material) {
        // A filament stands across the normal, so the hair cone turns as the ball does.
        case 0u: {
          var t = cross(n, vec3f(0.0, 1.0, 0.0));
          if (length(t) < 1e-3) { t = vec3f(1.0, 0.0, 0.0); }
          return fiberLight(normalize(t), v, p, scene.glow.w);
        }
        case 1u: { return plantLight(n, v, p, vec3f(0.16, 0.28, 0.08), 0.4); }
        default: { return vec3f(0.0); }
      }
    }
    
    fn grade(color: vec3f) -> vec3f { return toSrgb(aces(color * scene.zenith.w)); }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      var index = -1;
      for (var i = 0; i < i32(balls.view.x); i++) {
        let r = balls.tiles[i];
        if (all(position.xy >= r.xy) && all(position.xy < r.xy + r.zw)) { index = i; }
      }
      if (index < 0) { return vec4f(0.0); }
      let tile = balls.tiles[index];
      let kind = balls.kinds[index];
      let radius = kind.y;
      let ndc = ((position.xy - tile.xy) / tile.zw * 2.0 - 1.0) * vec2f(1.0, -1.0);
      let eye = normalize(vec3f(0.0, 0.2, 1.0)) * radius * balls.view.z;
      let forward = normalize(-eye);
      let right = normalize(cross(forward, vec3f(0.0, 1.0, 0.0)));
      let up = cross(right, forward);
      let rd = normalize(forward + (right * ndc.x + up * ndc.y) * balls.view.y);
    
      var color = environment(rd);
      let along = -dot(eye, rd);
      let miss = length(eye + rd * along);
      let pixel = length(eye) * 2.0 * balls.view.y / tile.w;
      let coverage = clamp((radius - miss) / pixel + 0.5, 0.0, 1.0);
      if (coverage > 0.0) {
        let t = along - sqrt(max(radius * radius - miss * miss, 0.0));
        let p = eye + rd * t;
        let q = turnY(p, kind.z);
        let n = normalize(q);
        let v = turnY(-rd, kind.z);
        color = mix(color, shadeBall(u32(kind.x), q, n, v), coverage);
      }
      let halfSize = tile.zw * 0.5;
      let corner = balls.view.w;
      let d = length(max(abs(position.xy - tile.xy - halfSize) - halfSize + corner, vec2f(0.0))) - corner;
      let alpha = clamp(0.5 - d, 0.0, 1.0);
      return vec4f(grade(color) * alpha, alpha);
    }

What the Sky Holds

2 matières · common.wgsl

Le ciel de la nuit et la houle du rivage. La sphère du ciel regarde dans la direction de sa normale ; celle de la houle en renvoie le reflet, selon la pente des vagues.

Compilation des shaders de la scène…Cet aperçu n’a pas pu démarrer sur ce navigateur.

CielaboveLa nuit au-dessus de la mer : bleu profond, étoiles, lueurs de la côte.
HouleswellSlopeLa pente des vagues, et le ciel qu’elle renvoie.
  • essaim.wgslfragment Night sky and swell for /shaders. The sky ball is above() in the direction of its normal. The swell ball takes its slope from swellSlope and reflects that sky, the way the sea does. 66 lignes
    Fichier
    src/materials/essaim.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    1 liaison
    // Night sky and swell for /shaders. The sky ball is above() in the direction of its normal. The swell
    // ball takes its slope from swellSlope and reflects that sky, the way the sea does.
    
    struct Balls {
      tiles: array<vec4f, 8>,
      kinds: array<vec4f, 8>,
      view: vec4f,
    };
    @group(0) @binding(11) var<uniform> balls: Balls;
    
    fn turnY(v: vec3f, angle: f32) -> vec3f {
      let c = cos(angle);
      let s = sin(angle);
      return vec3f(c * v.x + s * v.z, v.y, -s * v.x + c * v.z);
    }
    
    fn shadeBall(material: u32, q: vec3f, n: vec3f, rd: vec3f, pixel: f32, turn: f32) -> vec3f {
      switch (material) {
        case 0u: { return above(n); }
        case 1u: {
          let slope = swellSlope(q.xz, pixel);
          let east = normalize(cross(vec3f(0.0, 1.0, 0.0), n) + vec3f(1e-4, 0.0, 0.0));
          let north = cross(n, east);
          let water = normalize(n - east * slope.x - north * slope.y);
          return above(reflect(rd, turnY(water, -turn))) * scene.sea.y;
        }
        default: { return vec3f(0.0); }
      }
    }
    
    fn grade(color: vec3f) -> vec3f { return toSrgb(aces(color * scene.viewport.w)); }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      var index = -1;
      for (var i = 0; i < i32(balls.view.x); i++) {
        let r = balls.tiles[i];
        if (all(position.xy >= r.xy) && all(position.xy < r.xy + r.zw)) { index = i; }
      }
      if (index < 0) { return vec4f(0.0); }
      let tile = balls.tiles[index];
      let kind = balls.kinds[index];
      let radius = kind.y;
      let ndc = ((position.xy - tile.xy) / tile.zw * 2.0 - 1.0) * vec2f(1.0, -1.0);
      let eye = normalize(vec3f(0.0, 0.2, 1.0)) * radius * balls.view.z;
      let forward = normalize(-eye);
      let right = normalize(cross(forward, vec3f(0.0, 1.0, 0.0)));
      let up = cross(right, forward);
      let rd = normalize(forward + (right * ndc.x + up * ndc.y) * balls.view.y);
    
      var color = above(rd);
      let along = -dot(eye, rd);
      let miss = length(eye + rd * along);
      let pixel = length(eye) * 2.0 * balls.view.y / tile.w;
      let coverage = clamp((radius - miss) / pixel + 0.5, 0.0, 1.0);
      if (coverage > 0.0) {
        let t = along - sqrt(max(radius * radius - miss * miss, 0.0));
        let p = eye + rd * t;
        let q = turnY(p, kind.z);
        color = mix(color, shadeBall(u32(kind.x), q, normalize(q), rd, pixel, kind.z), coverage);
      }
      let halfSize = tile.zw * 0.5;
      let corner = balls.view.w;
      let d = length(max(abs(position.xy - tile.xy - halfSize) - halfSize + corner, vec2f(0.0))) - corner;
      let alpha = clamp(0.5 - d, 0.0, 1.0);
      return vec4f(grade(color) * alpha, alpha);
    }

La mémoire du sable

2 matières · terrain.wgsl

Les rides, les grains, les mottes et les paillettes du prototype, et son atmosphère calculée par ses propres tables. Le sable est posé sur une sphère : chaque point est éclairé dans son plan tangent, comme le prototype éclaire le sol.

Compilation des shaders de la scène…Cet aperçu n’a pas pu démarrer sur ce navigateur.

Sable ridérippleSunDeux familles de rides qui s’ombrent l’une l’autre, des grains de 3 mm, des paillettes de quartz.
Face d’avalanchesandAlbedoSans rides : des coulées de sable plus pâle qui descendent la pente.
  • dune.wgslfragment Sand balls for /shaders: the dune prototype's sand, lifted from its files with everything it uses (src/materials/wgsl.ts), lit by its own atmosphere tables. Its ripples, grains, clods and glitter are written for a ground with y up, so each point of the ball is lit in its tangent plane, where that holds. The plane's coordinates are unrolled around the ball (longitude × radius, height): an equal-area map, whose slopes and gradients are rescaled back to true lengths before they are lit. 141 lignes
    Fichier
    src/materials/dune.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    1 liaison
    // Sand balls for /shaders: the dune prototype's sand, lifted from its files with everything it uses
    // (src/materials/wgsl.ts), lit by its own atmosphere tables. Its ripples, grains, clods and glitter are
    // written for a ground with y up, so each point of the ball is lit in its tangent plane, where that holds.
    // The plane's coordinates are unrolled around the ball (longitude × radius, height): an equal-area map,
    // whose slopes and gradients are rescaled back to true lengths before they are lit.
    
    struct Balls {
      tiles: array<vec4f, 8>, // x, y, width, height of each tile (device px)
      kinds: array<vec4f, 8>, // x: material (catalogue order), y: radius (m), z: turn (rad)
      view: vec4f,            // x: tile count, y: tan(half field of view), z: camera distance (radii), w: corner radius (px)
    };
    @group(0) @binding(11) var<uniform> balls: Balls;
    
    /** Nothing on the ball's horizon to hide the sun: no dune upwind of it. */
    fn sunVisibility(p: vec3f) -> f32 { return 1.0; }
    
    fn turnY(v: vec3f, angle: f32) -> vec3f {
      let c = cos(angle);
      let s = sin(angle);
      return vec3f(c * v.x + s * v.z, v.y, -s * v.x + c * v.z);
    }
    
    /**
     * The sand of fs_terrain (terrain.wgsl) at one point, in its tangent frame (x east, y the normal, z north):
     * `q` is the point on the unrolled map, `stretch` how many map units one metre spans along x and z there.
     * The dune's own macro slope is flat here, and the trace in the sand is not drawn.
     */
    fn shadeSand(slip: f32, q: vec2f, stretch: vec2f, frame: mat3x3f, l: vec3f, v: vec3f, pixel: f32) -> vec3f {
      let toward = normalize(l.xz + vec2f(1e-5, 0.0));
      let rise = l.y / max(length(l.xz), 1e-4);
      let features = sandFeatures(q, pixel, normalize(toward * stretch), rise);
      let featureSlope = features.yz * stretch;
      let featureMask = clamp(length(featureSlope) * 3.0, 0.0, 1.0);
      let surfaceRise = rise - dot(featureSlope, toward);
    
      let keep = (1.0 - slip) * scene.look.z;
      let farDir = vec2f(cos(scene.wind.w), sin(scene.wind.w));
      var fine = rippleSet(q, farDir, 0.115, 1.25, 0.058, 3.0);
      var coarse = rippleSet(q, normalize(farDir + vec2f(0.15, 0.1)), 0.72, 0.9, 0.009, 21.0);
      fine.grad *= stretch;
      coarse.grad *= stretch;
      let heightA = fine.height * keep * (1.0 - featureMask);
      let heightB = coarse.height * keep * (1.0 - 0.6 * featureMask);
    
      // How many ripples the pixel spans: supersample the footprint, or average over a period.
      let wA = length(fine.grad) * pixel;
      let wB = length(coarse.grad) * pixel;
      let averageA = wA > 1.2;
      let averageB = wB > 1.2;
      let count = select(clamp(i32(ceil(max(wA, wB) * 3.0)), 1, 8), 8, averageA || averageB);
      var offsets = array<vec2f, 8>(
        vec2f(0.0625, -0.1875), vec2f(-0.0625, 0.1875), vec2f(0.3125, 0.0625), vec2f(-0.1875, -0.3125),
        vec2f(-0.3125, 0.3125), vec2f(-0.4375, -0.0625), vec2f(0.1875, 0.4375), vec2f(0.4375, -0.4375));
      var sum = RippleLight(0.0, 0.0, vec2f(0.0), 0.0);
      for (var k = 0; k < count; k++) {
        let d = select(offsets[k], vec2f(0.0), count == 1) * pixel;
        let phaseA = select(fine.phase + dot(fine.grad, d), (f32(k) + 0.5) * 0.125, averageA);
        let phaseB = select(coarse.phase + dot(coarse.grad, d), fract(f32(k) * 0.618 + 0.31), averageB);
        let one = rippleSun(phaseA, fine.grad, heightA, phaseB, coarse.grad, heightB, featureSlope, l, v, toward, surfaceRise);
        sum.direct += one.direct;
        sum.lit += one.lit;
        sum.slope += one.slope;
        sum.height += one.height;
      }
      let inv = 1.0 / f32(count);
      var direct = sum.direct * inv * features.w;
      let lit = sum.lit * inv * features.w;
      let crestiness = sum.height * inv;
      let micro = normalize(vec3f(-sum.slope.x * inv, 1.0, -sum.slope.y * inv));
    
      let grains = sandGrains(q, pixel);
      direct *= clamp(1.0 - dot(grains.slope * stretch, toward) / max(surfaceRise, 0.06), 0.15, 2.4);
      let sun = sunlightAt(0.0) * sunVisibility(vec3f(0.0)) * SUN_ON_SAND;
      let ripples = keep * clamp(heightA / 0.008, 0.0, 1.0);
      let cavity = 1.0 - 0.28 * (1.0 - crestiness) * ripples;
      let ambient = skyIrradiance(frame * micro) * cavity * SKY_ON_SAND;
      // A slip face falls down the ball: south on the map.
      let albedo = sandAlbedo(q, pixel, smoothstep(0.6, 0.95, crestiness) * ripples, slip, vec2f(0.0, -1.0)) * grains.tone;
      let sparkle = glitter(q, micro, l, v, pixel) * lit * scene.look.w * 3.0;
      let h = normalize(l + v);
      let sheen = pow(max(dot(micro, h), 0.0), 24.0) * 0.035 * lit;
      let litFlank = clamp(0.15 + surfaceRise * 2.5, 0.0, 0.6);
      let hollows = max(ripples, 0.5 * featureMask);
      let bounce = albedo * sun * litFlank * 0.45 * (1.0 - lit) * hollows;
      return albedo / PI * (sun * direct + ambient + bounce) + sun * (sparkle + sheen);
    }
    
    /** Behind the ball: the prototype's sky (as fs_atmosphere draws it), and the erg below the horizon in its haze. */
    fn backdrop(rd: vec3f) -> vec3f {
      if (rd.y >= 0.0) { return skyGrade(rd, clouds(rd, skyRadiance(rd))) + sunDisk(rd); }
      let level = normalize(vec3f(rd.x, 0.0, rd.z) + vec3f(0.0, 1e-3, 0.0));
      let haze = skyGrade(level, skyRadiance(level));
      let erg = vec3f(0.6, 0.45, 0.28) / PI * (sunlightAt(0.0) * max(scene.sun.y, 0.0) * SUN_ON_SAND + skyIrradiance(vec3f(0.0, 1.0, 0.0)) * SKY_ON_SAND);
      return mix(haze, erg, smoothstep(0.0, 0.3, -rd.y));
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      var index = -1;
      for (var i = 0; i < i32(balls.view.x); i++) {
        let r = balls.tiles[i];
        if (all(position.xy >= r.xy) && all(position.xy < r.xy + r.zw)) { index = i; }
      }
      if (index < 0) { return vec4f(0.0); }
      let tile = balls.tiles[index];
      let kind = balls.kinds[index];
      let radius = kind.y;
      let ndc = ((position.xy - tile.xy) / tile.zw * 2.0 - 1.0) * vec2f(1.0, -1.0);
      // The prototype's observer looks toward +z: so does this camera, a little above the ball.
      let eye = normalize(vec3f(0.0, 0.15, -1.0)) * radius * balls.view.z;
      let forward = normalize(-eye);
      let right = normalize(cross(forward, vec3f(0.0, 1.0, 0.0)));
      let up = cross(right, forward);
      let rd = normalize(forward + (right * ndc.x + up * ndc.y) * balls.view.y);
    
      var color = backdrop(rd);
      let along = -dot(eye, rd);
      let miss = length(eye + rd * along);
      let pixel = length(eye) * 2.0 * balls.view.y / tile.w;
      let coverage = clamp((radius - miss) / pixel + 0.5, 0.0, 1.0);
      if (coverage > 0.0) {
        let t = along - sqrt(max(radius * radius - miss * miss, 0.0));
        let p = eye + rd * t;
        // Everything in the ball's own turning frame, so the sand turns and the light stays.
        let n = normalize(turnY(p, kind.z));
        let east = normalize(cross(vec3f(0.0, 1.0, 0.0), n) + vec3f(1e-5, 0.0, 0.0));
        let north = cross(n, east);
        let c = max(length(n.xz), 0.2);
        let q = vec2f(atan2(n.x, n.z) * radius, n.y * radius);
        let l = turnY(scene.sun.xyz, kind.z);
        let v = turnY(-rd, kind.z);
        let local = transpose(mat3x3f(east, n, north));
        let sand = shadeSand(select(0.0, 1.0, u32(kind.x) == 1u), q, vec2f(1.0 / c, c), mat3x3f(turnY(east, -kind.z), turnY(n, -kind.z), turnY(north, -kind.z)), local * l, local * v, pixel);
        color = mix(color, sand, coverage);
      }
      let halfSize = tile.zw * 0.5;
      let corner = balls.view.w;
      let d = length(max(abs(position.xy - tile.xy - halfSize) - halfSize + corner, vec2f(0.0))) - corner;
      let alpha = clamp(0.5 - d, 0.0, 1.0);
      // The prototype's camera (present.wgsl): white balance near 4300 K, exposure, AgX.
      return vec4f(toSrgb(agx(color * WHITE_BALANCE * scene.look.x)) * alpha, alpha);
    }

Après la pluie

WebGPU · WGSL · vgpu · Three.js

6 shaders · 152 lignes · 1 partagé

Ouvrir la scène
  • common.wgslmodule WGSL, sans commentaire d’en-tête. 20 lignes
    Fichier
    src/pluie/common.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    1 liaison
    struct Scene { shadowMatrix:mat4x4f, vp: mat4x4f, inverse: mat4x4f, eye: vec4f, viewport: vec4f, controls: vec4f };
    @group(0) @binding(0) var<uniform> scene: Scene;
    fn hash(p: vec2f) -> f32 { return fract(sin(dot(p,vec2f(127.1,311.7)))*43758.5453); }
    fn noise(p: vec2f) -> f32 { let i=floor(p); let f=fract(p); let u=f*f*(3.-2.*f); return mix(mix(hash(i),hash(i+vec2f(1,0)),u.x),mix(hash(i+vec2f(0,1)),hash(i+1.),u.x),u.y); }
    fn fog(c:vec3f,p:vec3f) -> vec3f { let d=distance(p,scene.eye.xyz); let f=1.-exp(-max(d-7.,0.)*(.008+.02*exp(-max(p.y,0.)*.11))); return mix(c,vec3f(.07,.13,.155)*(0.85+0.15*clamp(p.y/30.,0.,1.)),f); }
    fn light(p:vec3f,n:vec3f,v:vec3f,base:vec3f,lp:vec3f,lc:vec3f,rough:f32) -> vec3f {
     let delta=lp-p; let dist2=dot(delta,delta); let l=normalize(delta); let h=normalize(l+v); let nl=max(dot(n,l),0.); let nh=max(dot(n,h),0.); let nv=max(dot(n,v),.04); let a=rough*rough; let a2=a*a; let d=a2/(3.14159*pow(nh*nh*(a2-1.)+1.,2.)); let k=(rough+1.)*(rough+1.)/8.; let vis=(nl/(nl*(1.-k)+k))*(nv/(nv*(1.-k)+k)); let fr=.04+.96*pow(1.-max(dot(h,v),0.),5.); return (base*nl/3.14159+vec3f(d*vis*fr/max(4.*nl*nv,.001)))*lc/(1.+dist2);
    }
    fn illumination(p:vec3f,n:vec3f,base:vec3f,rough:f32) -> vec3f {
     let v=normalize(scene.eye.xyz-p); var c=base*(vec3f(.052,.092,.112)+max(dot(n,normalize(vec3f(-.3,1.,-.4))),0.)*vec3f(.12,.2,.23));
     c+=light(p,n,v,base,vec3f(4.,5.1,-5.),vec3f(23.,.65,.3),rough);
     c+=light(p,n,v,base,vec3f(4.1,3.13,-17.375),vec3f(.3,12.,16.),rough);
     c+=light(p,n,v,base,vec3f(4.,6.96,7.375),vec3f(.25,15.,20.),rough);
     c+=light(p,n,v,base,vec3f(4.1,8.7,-5.),vec3f(16.,9.,3.8),rough);
     c+=light(p,n,v,base,vec3f(-4.5,4.,-8.),vec3f(7.,9.,8.),rough);
     c+=light(p,n,v,base,vec3f(4.,4.5,-26.),vec3f(6.,8.,9.),rough);
     c+=light(p,n,v,base,vec3f(-4.,4.,-42.),vec3f(9.,9.,7.),rough);
     c+=light(p,n,v,base,vec3f(-1.,5.,6.),vec3f(2.3,4.1,4.8),max(rough,.5));
     return c;
    }
  • scene.wgslvertexfragment WGSL, sans commentaire d’en-tête. 49 lignes
    Fichier
    src/pluie/scene.wgsl
    Points d’entrée
    vs_main vertex, fs_main fragment
    Ressources
    8 liaisons
    struct Vertex { pos:vec4f, normal:vec4f, uv:vec4f };
    @group(0) @binding(1) var<storage,read> mesh:array<Vertex>;
    @group(0) @binding(2) var atlas:texture_2d<f32>;
    @group(0) @binding(3) var materialSampler:sampler;
    @group(0) @binding(4) var shadowTex:texture_depth_2d;
    @group(0) @binding(5) var shadowSampler:sampler_comparison;
    fn shadowAt(p:vec3f)->f32 {let q=scene.shadowMatrix*vec4f(p,1.);let uv=q.xy/q.w*vec2f(.5,-.5)+.5;var visibility=0.;for(var x=-1;x<=1;x++){for(var y=-1;y<=1;y++){visibility+=textureSampleCompareLevel(shadowTex,shadowSampler,uv+vec2f(f32(x),f32(y))/2048.,q.z/q.w-.0008);}}return visibility/9.;}
    @group(0) @binding(6) var airconColor:texture_2d<f32>;
    @group(0) @binding(7) var airconNormal:texture_2d<f32>;
    @group(0) @binding(8) var airconArm:texture_2d<f32>;
    struct Out { @builtin(position) position:vec4f, @location(0) world:vec3f, @location(1) normal:vec3f, @location(2) uv:vec2f, @location(3) @interpolate(flat) mat:f32, @location(4) glow:f32 };
    @vertex fn vs_main(@builtin(vertex_index) i:u32) -> Out { let v=mesh[i]; var p=v.pos.xyz; p.y*=scene.controls.x; return Out(scene.vp*vec4f(p,1.),v.pos.xyz,v.normal.xyz,v.uv.xy,v.pos.w,v.uv.z); }
    @fragment fn fs_main(i:Out,@builtin(front_facing) front:bool) -> @location(0) vec4f {
     let mat=u32(i.mat+.1); var n=normalize(i.normal); n=select(-n,n,front == (scene.controls.x>0.));
     let tile=min(mat,3u);
     var surfaceUV=i.uv;
     if(mat==0u || (mat==1u && abs(n.x)>.6)) {surfaceUV=vec2f(i.world.z,-i.world.y)/3.;if(abs(n.z)>.7){surfaceUV=vec2f(i.world.x,-i.world.y)/3.;}}
     let uv=(fract(surfaceUV)*.992+.004+vec2f(f32(tile%2u),f32(tile/2u)))*.5;
     let tex=textureSample(atlas,materialSampler,uv).rgb;
     let tx=textureSample(atlas,materialSampler,uv+vec2f(.0006,0.)).rgb;
     let ty=textureSample(atlas,materialSampler,uv+vec2f(0.,.0006)).rgb;
     let dp1=dpdx(i.world); let dp2=dpdy(i.world); let uv1=dpdx(surfaceUV); let uv2=dpdy(surfaceUV);
     let q1=cross(dp2,n); let q2=cross(n,dp1); let tangent=q1*uv1.x+q2*uv2.x; let bitangent=q1*uv1.y+q2*uv2.y;
     let norm=inverseSqrt(max(max(dot(tangent,tangent),dot(bitangent,bitangent)),.000001));
     let bump=vec2f(dot(tx-tex,vec3f(.333)),dot(ty-tex,vec3f(.333)));
     n=normalize(n-(tangent*bump.x+bitangent*bump.y)*norm*2.5);
     var base=tex; var rough=.64;
     if(mat==0u) { let damp=.52+.48*noise(vec2f(i.world.z*.65,i.world.y*.11)); base*=vec3f(.58,.66,.68)*damp*(0.64+0.36*smoothstep(.1,2.8,i.world.y)); }
     if(mat==1u) { base*=.62; rough=.26+tex.r*.4; }
     if(mat==3u) { base*=.42; rough=.36; }
     if(mat==7u) { base=vec3f(.025,.048,.054)*(.4+noise(i.world.zy*3.)); rough=.12; }
     if(mat==8u) { base=vec3f(.025,.07,.043)*(0.5+tex.g); rough=.38; }
     if(mat==9u) { base=vec3f(.033,.043,.045)*(0.6+tex.r); rough=.31; }
     if(mat==10u){
     let ac=textureSampleGrad(airconColor,materialSampler,i.uv,uv1,uv2);if(ac.a<.35){discard;}
     let acNormal=textureSampleGrad(airconNormal,materialSampler,i.uv,uv1,uv2).xyz*2.-1.;
     let arm=textureSampleGrad(airconArm,materialSampler,i.uv,uv1,uv2).rgb;
     n=normalize(normalize(i.normal)*acNormal.z+normalize(tangent)*acNormal.x-normalize(bitangent)*acNormal.y);
     base=ac.rgb*arm.r*.8;rough=clamp(arm.g*.8,.24,.9);
     }
     var c=illumination(i.world,n,base,rough);
     c*=.45+.55*shadowAt(i.world);
     if(mat>=4u && mat<=6u) {
     let colors=array<vec3f,3>(vec3f(.08,1.7,2.2),vec3f(3.4,.07,.027),vec3f(.9,.61,.34));
     c=colors[mat-4u]*i.glow;
     if(mat==6u) { c*=.32+.4*noise(i.world.zy*1.4); }
     }
     return vec4f(fog(c,i.world),1.);
    }
  • sky.wgslfragment WGSL, sans commentaire d’en-tête. 4 lignes
    Fichier
    src/pluie/sky.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    0 liaison
    @fragment fn fs_main(@location(0) uv:vec2f) -> @location(0) vec4f {
     let p=scene.inverse*vec4f(uv*vec2f(2.,-2.)+vec2f(-1.,1.),1.,1.); let rd=normalize(p.xyz/p.w-scene.eye.xyz);
     return vec4f(mix(vec3f(.075,.16,.19),vec3f(.013,.039,.055),clamp(abs(rd.y)*1.2,0.,1.)),1.);
    }
  • ground.wgslvertexfragment WGSL, sans commentaire d’en-tête. 46 lignes
    Fichier
    src/pluie/ground.wgsl
    Points d’entrée
    vs_main vertex, fs_main fragment
    Ressources
    6 liaisons
    @group(0) @binding(1) var reflection:texture_2d<f32>;
    @group(0) @binding(2) var asphaltColor:texture_2d<f32>;
    @group(0) @binding(4) var asphaltNormal:texture_2d<f32>;
    @group(0) @binding(5) var asphaltHeight:texture_2d<f32>;
    @group(0) @binding(6) var repeatSampler:sampler;
    @group(0) @binding(3) var linearSampler:sampler;
    struct Out { @builtin(position) position:vec4f, @location(0) world:vec3f };
    @vertex fn vs_main(@builtin(vertex_index) i:u32) -> Out { let v=array<vec2f,6>(vec2f(-80.,-210.),vec2f(80.,-210.),vec2f(-80.,20.),vec2f(-80.,20.),vec2f(80.,-210.),vec2f(80.,20.))[i]; let p=vec3f(v.x,0.,v.y); return Out(scene.vp*vec4f(p,1.),p); }
    fn waves(p:vec2f) -> vec2f {
     var d=vec2f(0.); let grid=p*1.5; let cell=floor(grid); let t=scene.eye.w;
     for(var x=-1;x<=1;x++){ for(var y=-1;y<=1;y++){
     let id=cell+vec2f(f32(x),f32(y)); let h=hash(id); let center=id+vec2f(h,hash(id+13.)); let diff=grid-center; let r=length(diff); let age=fract(t*.65+h*12.); let ring=r-age*1.8;
     d+=normalize(diff+0.001)*cos(ring*48.)*exp(-abs(ring)*21.)*(1.-age)*.014;
     }}
     return d;
    }
    @fragment fn fs_main(i:Out) -> @location(0) vec4f {
     let p=i.world.xz; let t=scene.eye.w; let d=distance(i.world,scene.eye.xyz);
     let texuv=p*.42;
     let albedo=textureSample(asphaltColor,repeatSampler,texuv).rgb;
     let normalMap=textureSample(asphaltNormal,repeatSampler,texuv).xyz*2.-1.;
     let height=textureSample(asphaltHeight,repeatSampler,texuv).r;
     let micro=vec2f(noise(p*75.+vec2f(t*.4,0.)),noise(p*77.-vec2f(0.,t*.3)))-.5;
     let ripple=waves(p)*scene.controls.z;
     let basin=noise(p*.52)*.4+noise(p*2.1)*.18-height*.6+.4;
     let water=smoothstep(.22,.40,basin);
     let distortion=(micro*vec2f(.055,.075)+vec2f(noise(p*vec2f(2.,16.)+t*.03)-.5,noise(p*vec2f(4.,22.))-.5)*.08+ripple+vec2f(sin(p.y*17.+t),cos(p.x*12.+t))*.005)*water+normalMap.xy*.09*(1.-water);
     let gravel=normalMap.xy;
     let n=normalize(vec3f(distortion.x+gravel.x*.55*(1.-water),1.,distortion.y+gravel.y*.55*(1.-water)));
     let view=normalize(scene.eye.xyz-i.world); let fres=.04+.96*pow(1.-max(dot(n,view),0.),5.);
     let screen=i.position.xy/scene.viewport.xy;
     let offset=distortion*vec2f(.75,.4)/max(d*.18,1.);
     var reflected=vec3f(0.);
     for(var tap=-6;tap<=6;tap++){let delta=vec2f(f32(tap)*.0007,f32(tap)*.0045);reflected+=textureSampleLevel(reflection,linearSampler,clamp(screen+offset+delta,vec2f(.001),vec2f(.999)),0.).rgb/13.;}
     reflected*=.7+.3*smoothstep(.25,.6,noise(p*5.));
     let base=albedo*.3*(.55+height*.6);
     let diffuse=illumination(i.world,n,base,.62);
     var c=mix(diffuse,reflected,clamp((.4+fres*.55)*water+.13,0.,.94));
     c+=max(illumination(i.world,n,base,.28)-diffuse,vec3f(0.))*.75*water;
     let skyGlint=pow(max(0.,dot(reflect(-view,n),normalize(vec3f(.7,.3,-.6)))),12.);
     c+=vec3f(.07,.13,.16)*skyGlint*(.3+fres)*pow(height,3.);
     c+=vec3f(.025,.052,.065)*(fres+.15)*(normalMap.z*.35+abs(normalMap.x)*.9)*(.3+water);
     c+=vec3f(.6,.85,.94)*length(ripple)*4.5;
     let floorFog=1.-exp(-max(d-17.,0.)*.018);
     return vec4f(mix(c,vec3f(.06,.115,.14),floorFog),1.);
    }
  • weather.wgslvertexfragment WGSL, sans commentaire d’en-tête. 23 lignes
    Fichier
    src/pluie/weather.wgsl
    Points d’entrée
    vs_main vertex, fs_main fragment
    Ressources
    0 liaison
    struct Out { @builtin(position) position:vec4f, @location(0) uv:vec2f, @location(1) @interpolate(flat) kind:f32, @location(2) color:vec4f };
    @vertex fn vs_main(@builtin(vertex_index) vi:u32,@builtin(instance_index) id:u32) -> Out {
     let corners=array<vec2f,6>(vec2f(-1,-1),vec2f(1,-1),vec2f(-1,1),vec2f(-1,1),vec2f(1,-1),vec2f(1,1)); let uv=corners[vi];
     let h=hash(vec2f(f32(id),2.)); let h2=hash(vec2f(f32(id),19.)); let h3=hash(vec2f(f32(id),31.)); let t=scene.eye.w;
     var p=vec3f(h*11.-6.,fract(h2-t*(.65+h*.35))*16.,8.-h3*85.);
     if(id<450u){p=vec3f(-5.3+h*.32,fract(h2-t*1.2)*5.,2.+h3*4.);}
     var size=vec2f(.006,.11+h*.07); var alpha=.08+h*.12; var color=vec3f(.25,.48,.58); var kind=0.;
     if(id>=7000u) {
     let age=fract(t*.7+h*23.); p=vec3f(h*9.-5.,.018,9.-h2*58.); size=vec2f(age*(.12+h*.2)+.01); alpha=pow(1.-age,2.)*.62; kind=1.; color=illumination(p,vec3f(0,1,0),vec3f(.5),.4)*1.5+vec3f(.12,.22,.26);
     p+=vec3f(uv.x*size.x,0.,uv.y*size.y);
     } else { p+=vec3f(uv.x*size.x+uv.y*.015,uv.y*size.y,0.); }
     if(id>=8600u) {
     let age=fract(t*.1+h); p=vec3f(3.8-age*.8+sin(h*40.+t*.3)*.25,.4+age*2.4,2.8+sin(h2*21.)*.65); size=vec2f(.28+age*.85); p+=vec3f(uv.x*size.x,uv.y*size.y,0.); alpha=sin(age*3.14159)*.025; color=vec3f(.25,.32,.34); kind=2.;
     }
     alpha*=scene.controls.z;
     let projected=scene.vp*vec4f(p,1.); return Out(projected,uv,kind,vec4f(color,alpha));
    }
    @fragment fn fs_main(i:Out) -> @location(0) vec4f {
     var a=(1.-abs(i.uv.x))*smoothstep(1.,.4,abs(i.uv.y));
     if(i.kind==1.) { let r=length(i.uv); a=(exp(-abs(r-.78)*65.)*.7+exp(-abs(r-.54)*70.)*.25)*(.35+.65*noise(i.uv*12.)); }
     if(i.kind==2.) { a=exp(-dot(i.uv,i.uv)*3.)*smoothstep(1.,.5,length(i.uv))*(.45+noise(i.uv*5.+scene.eye.w*.13)); }
     return vec4f(i.color.rgb,a*i.color.a);
    }
  • present.wgslfragment WGSL, sans commentaire d’en-tête. 10 lignes
    Fichier
    src/pluie/present.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    3 liaisons
    @group(0) @binding(1) var hdr:texture_2d<f32>;
    @group(0) @binding(2) var bloom:texture_2d<f32>;
    @group(0) @binding(3) var linearSampler:sampler;
    fn aces(x:vec3f)->vec3f {return clamp(x*(2.51*x+.03)/(x*(2.43*x+.59)+.14),vec3f(0.),vec3f(1.));}
    @fragment fn fs_main(@builtin(position) p:vec4f,@location(0) uv:vec2f)->@location(0) vec4f {
     var c=textureSampleLevel(hdr,linearSampler,uv,0.).rgb+textureSampleLevel(bloom,linearSampler,uv,0.).rgb*.026;
     c*=scene.controls.y*(1.-.25*smoothstep(.2,.8,length(uv-.5)));
     c=aces(c); c=pow(c,vec3f(1./2.2)); c=mix(c,c*c*(3.-2.*c),.2);
     return vec4f(c+(hash(p.xy)-.5)/400.,1.);
    }
  • bloom.wgslPartagé, présenté avec Ce qui flotte

Soundwave

WebGPU · WGSL · vgpu

1 shader · 92 lignes

Ouvrir la scène
  • soundwave.wgslvertexfragment WGSL, sans commentaire d’en-tête. 92 lignes
    Fichier
    src/soundwave.wgsl
    Points d’entrée
    vertex_main vertex, fragment_main fragment
    Ressources
    1 liaison
    struct Params {
      time: f32,
      spacing: f32,
      bass: f32,
      mid: f32,
      treble: f32,
      level: f32,
      visualDrive: f32,
      moodEnergy: f32,
      moodBrightness: f32,
      pulse: f32,
      motionScale: f32,
      customBlend: f32,
      aspect: f32,
      yaw: f32,
      pitch: f32,
      cameraDistance: f32,
      color: vec3f,
    };
    
    @group(0) @binding(0) var<uniform> params: Params;
    
    struct VertexOutput {
      @builtin(position) clip: vec4f,
      @location(0) uv: vec2f,
      @location(1) depth: f32,
    };
    
    @vertex
    fn vertex_main(
      @builtin(vertex_index) vertexIndex: u32,
      @builtin(instance_index) instanceIndex: u32,
      @location(0) customPosition: vec4f,
    ) -> VertexOutput {
      let corners = array<vec2f, 6>(
        vec2f(0.0, 0.0), vec2f(1.0, 0.0), vec2f(0.0, 1.0),
        vec2f(0.0, 1.0), vec2f(1.0, 0.0), vec2f(1.0, 1.0),
      );
      let uv = corners[vertexIndex];
      let row = f32(instanceIndex / 50u);
      let column = f32(instanceIndex % 50u);
      let extent = params.spacing * 0.5;
      let lattice = vec3f((vec2f(column, row) - vec2f(25.0)) * params.spacing, 0.0);
      let base = mix(lattice, customPosition.xyz * extent, params.customBlend);
      let distance = length(base.xy);
      let wave = sin(distance * 0.5 - params.time * 2.2) *
        (1.4 + params.visualDrive * 1.5 + params.bass * 12.0 + params.pulse * 5.0) +
        sin(distance * 1.1 - params.time * 3.5) * params.mid * 5.0 +
        sin((column + row) * 0.28 - params.time * 6.0) * params.treble * 2.0;
      let breath = min(1.34, 1.0 + (params.bass * (0.13 + params.moodEnergy * 0.11) +
        params.pulse * 0.22) * params.motionScale);
      let pointSize = mix(1.5, 0.78, params.customBlend) *
        (1.0 + (params.treble * 0.2 + params.pulse * 0.14) * params.motionScale);
      let world = vec3f(base.xy * breath + (uv - vec2f(0.5)) * pointSize,
        base.z + wave * params.motionScale);
    
      let cp = cos(params.pitch);
      let camera = vec3f(
        sin(params.yaw) * cp,
        sin(params.pitch),
        cos(params.yaw) * cp,
      ) * params.cameraDistance;
      let forward = normalize(-camera);
      let right = normalize(cross(forward, vec3f(0.0, 1.0, 0.0)));
      let up = cross(right, forward);
      let relative = world - camera;
      let cameraZ = dot(relative, forward);
      let focal = 1.0 / tan(0.36651915); // 42° vertical field of view.
    
      var result: VertexOutput;
      result.clip = vec4f(
        dot(relative, right) * focal / max(params.aspect, 0.01),
        dot(relative, up) * focal,
        cameraZ * 0.5,
        max(cameraZ, 0.01),
      );
      result.uv = uv;
      result.depth = cameraZ;
      return result;
    }
    
    @fragment
    fn fragment_main(input: VertexOutput) -> @location(0) vec4f {
      let radius = mix(0.31 + params.moodEnergy * 0.035 + params.treble * 0.15,
        0.28 + params.treble * 0.08, params.customBlend);
      let circle = 1.0 - smoothstep(radius - 0.02, radius + 0.02, length(input.uv - vec2f(0.5)));
      let fog = exp(-max(input.depth - 45.0, 0.0) * 0.005);
      let glow = (0.76 + params.moodBrightness * 0.34) *
        (1.0 + params.level * 1.1 + params.pulse * 0.8);
      let rgb = params.color * glow * fog;
      return vec4f(rgb, circle * fog);
    }

Le dernier souffle

WebGPU · WGSL

1 shader · 379 lignes

Ouvrir la scène
  • meadow.wgslvertex ×5fragment ×5 WGSL, sans commentaire d’en-tête. 379 lignes
    Fichier
    src/meadow.wgsl
    Points d’entrée
    backgroundVertex vertex, backgroundFragment fragment, mistVertex vertex, mistFragment fragment, birdVertex vertex, birdFragment fragment, fireflyVertex vertex, fireflyFragment fragment, particleVertex vertex, particleFragment fragment
    Ressources
    3 liaisons
    struct Scene {
      screen: vec4f, // framebuffer width, height, time, scale
      framing: vec4f, // reference offset x/y, wind, gust
      pointer: vec4f, // reference x/y, activity, dissolution seconds (0..24)
      mood: vec4f, // day/night mix, parallax x/y, accumulated cloud drift time
      camera: vec4f, // yaw radians, orbit pivot depth, focal length, backdrop depth
      lifecycle: vec4f, // rebirth progress (0..1), spatial motion allowed, reserved
    };
    @group(0) @binding(0) var<uniform> scene: Scene;
    @group(0) @binding(1) var landscape: texture_2d<f32>;
    @group(0) @binding(2) var landscapeSampler: sampler;
    
    fn light(color: vec3f, foreground: f32) -> vec3f {
      let day = max(scene.mood.x, 0.0);
      let night = max(-scene.mood.x, 0.0);
      let daylight = color * vec3f(1.19, 1.19, 1.13) + vec3f(0.16, 0.18, 0.17);
      let moonlight = color * vec3f(0.26, 0.35, 0.57) + vec3f(0.006, 0.012, 0.026);
      let lit = mix(mix(color, daylight, day), moonlight, night);
      let lum = dot(color, vec3f(0.3, 0.5, 0.2));
      return lit + night * foreground * smoothstep(0.45, 0.85, lum) * vec3f(0.15, 0.16, 0.17);
    }
    
    struct Backdrop { @builtin(position) position: vec4f, @location(0) uv: vec2f };
    
    fn cloudHash(p: vec2f) -> f32 {
      var h = fract(vec3f(p.x, p.y, p.x) * 0.1031);
      h += dot(h, h.yzx + 33.33);
      return fract((h.x + h.y) * h.z);
    }
    
    fn cloudNoise(p: vec2f) -> f32 {
      let cell = floor(p);
      let f = fract(p);
      let blend = f * f * (3.0 - 2.0 * f);
      return mix(
        mix(cloudHash(cell), cloudHash(cell + vec2f(1.0, 0.0)), blend.x),
        mix(cloudHash(cell + vec2f(0.0, 1.0)), cloudHash(cell + vec2f(1.0, 1.0)), blend.x),
        blend.y);
    }
    
    fn cloudMass(p: vec2f) -> f32 {
      return cloudNoise(p) * 0.55
        + cloudNoise(p * 2.03 + vec2f(11.7, 8.2)) * 0.27
        + cloudNoise(p * 4.11 + vec2f(4.3, 19.1)) * 0.13
        + cloudNoise(p * 8.21 + vec2f(21.1, 3.8)) * 0.05;
    }
    
    // One sparsely populated, jittered cell per patch of distant sky. Reference
    // coordinates keep the stars attached to the backdrop during camera turns.
    fn starlight(source: vec2f) -> vec3f {
      let grid = source / 43.0;
      let cell = floor(grid);
      let seed = cloudHash(cell + vec2f(73.2, 19.6));
      if (seed < 0.48) { return vec3f(0.0); }
      let center = 0.16 + 0.68 * vec2f(
        cloudHash(cell + vec2f(11.4, 97.1)),
        cloudHash(cell + vec2f(61.8, 32.7)));
      let distance = length((fract(grid) - center) * 43.0);
      let brilliance = cloudHash(cell + vec2f(29.3, 5.8));
      let radius = mix(0.55, 1.15, brilliance * brilliance);
      let aa = max(0.35, 0.65 / scene.screen.w);
      let core = 1.0 - smoothstep(max(0.0, radius - aa), radius + aa, distance);
      let halo = exp(-distance * distance / 5.0) * 0.12 * brilliance;
      let time = scene.screen.z * scene.lifecycle.y;
      let twinkle = 0.86 + 0.14 * sin(time * mix(0.55, 1.15, seed) + brilliance * 43.0);
      let tint = mix(vec3f(0.72, 0.83, 1.0), vec3f(1.0, 0.92, 0.77), brilliance);
      return tint * (core + halo) * mix(0.38, 0.95, brilliance) * twinkle;
    }
    
    @vertex fn backgroundVertex(@builtin(vertex_index) vertex: u32) -> Backdrop {
      let corners = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
      var out: Backdrop;
      out.position = vec4f(corners[vertex], 0.9, 1.0);
      out.uv = corners[vertex] * vec2f(0.5, -0.5) + 0.5;
      return out;
    }
    @fragment fn backgroundFragment(input: Backdrop) -> @location(0) vec4f {
      let pixel = input.uv * scene.screen.xy;
      let reference = (pixel - scene.framing.xy) / scene.screen.w;
      // Inverse perspective projection of the distant plane, matching the orbit
      // applied to every foreground particle below.
      let c = cos(scene.camera.x);
      let s = sin(scene.camera.x);
      let depth = scene.camera.w;
      let dz = depth - scene.camera.y;
      let ray = (reference.x - 836.0) / scene.camera.z;
      let worldX = (ray * (scene.camera.y + c * dz) - s * dz) / (c + ray * s);
      let viewZ = scene.camera.y - s * worldX + c * dz;
      let source = vec2f(836.0 + worldX * scene.camera.z / depth,
        470.5 + (reference.y - 470.5) * viewZ / depth);
      let uv = source / vec2f(1672.0, 941.0);
      let base = light(textureSample(landscape, landscapeSampler, uv).rgb, 0.0);
      // The old crossfade cancelled the net translation of broad cloud features.
      // Advect an unbounded density field instead: identifiable billows travel
      // rightward without resetting, wrapping, or dissolving into a second image.
      let sky = 1.0 - smoothstep(0.33, 0.435, uv.y);
      if (sky <= 0.0) { return vec4f(base, 1.0); }
      let drift = scene.mood.w;
      let position = uv * vec2f(5.2, 8.5);
      let near = cloudMass(position - vec2f(drift * 0.052, 0.0) + vec2f(3.1, 7.4));
      let far = cloudNoise(position * 0.65 - vec2f(drift * 0.019, 0.0) + vec2f(17.3, 4.9));
      let billows = smoothstep(0.38, 0.70, near);
      let veil = smoothstep(0.40, 0.76, far);
      let night = max(-scene.mood.x, 0.0);
      let starVisibility = smoothstep(0.12, 0.85, night)
        * (1.0 - smoothstep(0.25, 0.39, uv.y));
      var starrySky = base;
      if (starVisibility > 0.0) {
        let clearSky = (1.0 - smoothstep(0.22, 0.88, billows)) * (1.0 - veil * 0.75);
        starrySky += starlight(source) * starVisibility * clearSky;
      }
      // Silver edges keep movement readable at night without brightening the land.
      let silver = mix(light(vec3f(0.59, 0.64, 0.68), 0.0), vec3f(0.25, 0.31, 0.43), night);
      let shadow = mix(light(vec3f(0.25, 0.31, 0.38), 0.0), vec3f(0.075, 0.10, 0.18), night);
      let beneath = mix(starrySky, shadow, veil * sky * 0.24);
      let color = mix(beneath, silver, billows * sky * 0.62);
      return vec4f(color, 1.0);
    }
    
    struct Particle {
      @location(0) positionSize: vec4f,
      @location(1) color: vec4f,
      @location(2) behavior: vec4f,
    };
    struct Fragment {
      @builtin(position) position: vec4f,
      @location(0) local: vec2f,
      @location(1) color: vec4f,
      @location(2) softness: f32,
    };
    
    fn projectPlane(pos: vec2f, worldZ: f32) -> vec3f {
      let world = vec3f((pos - vec2f(836.0, 470.5)) * worldZ / scene.camera.z, worldZ - scene.camera.y);
      let c = cos(scene.camera.x);
      let s = sin(scene.camera.x);
      let viewZ = scene.camera.y - s * world.x + c * world.z;
      let projected = vec2f(c * world.x + s * world.z, world.y) * scene.camera.z / viewZ + vec2f(836.0, 470.5);
      return vec3f(projected, worldZ / viewZ);
    }
    
    fn projectMeadow(pos: vec2f, depth: f32) -> vec3f {
      return projectPlane(pos, 1800.0 - depth * 600.0);
    }
    
    struct MistFragment {
      @builtin(position) position: vec4f,
      @location(0) local: vec2f,
      @location(1) reference: vec2f,
      @location(2) variation: vec2f,
    };
    
    // Broad, translucent particles form three valley bands, ordered far to near.
    // No raster sprite: moving noise gives every wisp a soft, irregular density.
    @vertex fn mistVertex(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> MistFragment {
      let corners = array<vec2f, 6>(vec2f(-1.0,-1.0), vec2f(1.0,-1.0), vec2f(-1.0,1.0), vec2f(-1.0,1.0), vec2f(1.0,-1.0), vec2f(1.0,1.0));
      let id = f32(instance);
      let layer = floor(id / 16.0);
      let member = id - layer * 16.0;
      let seed = cloudHash(vec2f(id + 1.0, 127.4));
      let other = cloudHash(vec2f(id + 9.0, 41.8));
      let drift = scene.mood.w;
      // Wrap only well outside the reference frame, including the camera overscan.
      let x = fract((member * 150.0 + layer * 83.0 + drift * (7.0 + layer * 3.0)) / 2400.0) * 2400.0 - 360.0;
      let valley = 532.0 + layer * 48.0 + sin(x * 0.004 + layer * 1.7) * 12.0;
      let center = vec2f(x, valley + (other - 0.5) * 19.0 + sin(drift * 0.1 + seed * 6.283) * 4.0);
      let size = vec2f(135.0 + seed * 85.0, 24.0 + other * 18.0);
      let corner = corners[vertex];
      let reference = center + corner * size;
      let projected = projectPlane(reference, 3400.0 - layer * 340.0);
      let pixel = projected.xy * scene.screen.w + scene.framing.xy;
      var out: MistFragment;
      out.position = vec4f(pixel / scene.screen.xy * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0), 0.75, 1.0);
      out.local = corner;
      out.reference = reference;
      out.variation = vec2f(seed, layer);
      return out;
    }
    
    @fragment fn mistFragment(input: MistFragment) -> @location(0) vec4f {
      let seed = input.variation.x;
      let drift = scene.mood.w;
      let domain = input.local * vec2f(2.6, 1.7) + vec2f(seed * 31.0, seed * 17.0);
      let rolling = cloudNoise(domain + vec2f(-drift * 0.045, drift * 0.014));
      let detail = cloudNoise(domain * 2.4 + vec2f(drift * 0.022, 8.1));
      let density = smoothstep(0.18, 0.78, rolling * 0.72 + detail * 0.28);
      let shape = vec2f(input.local.x, input.local.y + (rolling - 0.5) * 0.4);
      let edge = 1.0 - smoothstep(0.25, 1.0, dot(shape, shape));
      let valleyMask = smoothstep(485.0, 525.0, input.reference.y)
        * (1.0 - smoothstep(665.0, 710.0, input.reference.y));
      let night = max(-scene.mood.x, 0.0);
      let day = max(scene.mood.x, 0.0);
      let silver = mix(vec3f(0.57, 0.62, 0.67), vec3f(0.70, 0.75, 0.76), day);
      let color = mix(silver, vec3f(0.14, 0.19, 0.28), night);
      let alpha = edge * density * valleyMask * (0.28 + input.variation.y * 0.035);
      return vec4f(color, alpha);
    }
    
    // Two loose, distant flocks cross the sky on the landscape's depth plane.
    // Their independent clock survives the flower cycle and obeys scene pause.
    @vertex fn birdVertex(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Fragment {
      let corners = array<vec2f, 6>(vec2f(-1.0,-1.0), vec2f(1.0,-1.0), vec2f(-1.0,1.0), vec2f(-1.0,1.0), vec2f(1.0,-1.0), vec2f(1.0,1.0));
      let id = f32(instance);
      let flock = floor(id / 4.0);
      let member = id - flock * 4.0;
      let seed = cloudHash(vec2f(id + 3.0, 54.1));
      let time = scene.screen.z;
      let crossing = fract((time * (13.0 + flock * 2.0) + 720.0 + flock * 640.0) / 2100.0) * 2100.0 - 120.0;
      let pos = vec2f(crossing - member * 29.0,
        440.0 - flock * 18.0 + abs(member - 1.0) * 6.0 + sin(time * 0.31 + id) * 3.0);
      let projected = projectPlane(pos, 3200.0 + flock * 160.0);
      let size = (0.75 + seed * 0.25) * scene.screen.w * projected.z;
      let corner = corners[vertex];
      let pixel = projected.xy * scene.screen.w + scene.framing.xy + corner * vec2f(6.0, 4.0) * size;
      // Brief glides interrupt soft, unsynchronised wingbeats.
      let glide = smoothstep(0.2, 0.8, sin(time * 0.43 + id));
      let flap = sin(time * (4.8 + seed) + seed * 6.283) * (1.0 - glide * 0.85);
      let night = max(-scene.mood.x, 0.0);
      var out: Fragment;
      out.position = vec4f(pixel / scene.screen.xy * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0), 0.8, 1.0);
      out.local = corner * vec2f(6.0, 4.0);
      out.color = vec4f(mix(vec3f(0.19, 0.23, 0.27), vec3f(0.025, 0.04, 0.075), night), mix(0.7, 0.42, night));
      out.softness = flap;
      return out;
    }
    
    @fragment fn birdFragment(input: Fragment) -> @location(0) vec4f {
      let p = input.local;
      let span = 4.6 - abs(input.softness) * 0.6;
      let t = clamp(abs(p.x) / span, 0.0, 1.0);
      let wingY = -sin(t * 2.5) * (0.9 + input.softness * 2.1) + t * 0.45;
      let wing = max(abs(p.y - wingY) - mix(0.32, 0.10, t), abs(p.x) - span);
      let body = length(p * vec2f(1.8, 1.0)) - 0.65;
      let distance = min(wing, body);
      let aa = max(fwidth(distance), 0.12);
      let alpha = (1.0 - smoothstep(-aa, aa, distance)) * input.color.a;
      return vec4f(input.color.rgb, alpha);
    }
    
    // These insects are a separate living layer, independent of flower dissolution.
    @vertex fn fireflyVertex(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Fragment {
      let corners = array<vec2f, 6>(vec2f(-1.0,-1.0), vec2f(1.0,-1.0), vec2f(-1.0,1.0), vec2f(-1.0,1.0), vec2f(1.0,-1.0), vec2f(1.0,1.0));
      let id = f32(instance);
      let seed = cloudHash(vec2f(id + 1.0, 83.7));
      let other = cloudHash(vec2f(id + 7.0, 19.3));
      let time = scene.screen.z;
      let phase = seed * 6.283185;
      let speed = 0.38 + other * 0.35;
      var pos = vec2f(180.0 + seed * 1330.0, 300.0 + other * 450.0);
      pos += vec2f(sin(time * speed + phase) * 32.0 + sin(time * 0.17 + id) * 18.0,
        cos(time * speed * 0.7 + phase) * 22.0 + sin(time * 0.29 + id) * 12.0);
      let projected = projectMeadow(pos, 0.25 + seed * 0.35);
      let radius = (8.0 + other * 4.0) * scene.screen.w * projected.z;
      let pixel = projected.xy * scene.screen.w + scene.framing.xy + corners[vertex] * radius;
      let night = smoothstep(0.08, 0.85, max(-scene.mood.x, 0.0));
      let pulse = 0.30 + 0.70 * pow(0.5 + 0.5 * sin(time * (0.85 + other * 0.6) + phase), 2.0);
      var out: Fragment;
      out.position = vec4f(pixel / scene.screen.xy * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0), 0.6, 1.0);
      out.local = corners[vertex];
      out.color = vec4f(1.0, 0.94, 0.48, pulse);
      out.softness = night;
      return out;
    }
    
    @fragment fn fireflyFragment(input: Fragment) -> @location(0) vec4f {
      let r2 = dot(input.local, input.local);
      if (r2 > 1.0) { discard; }
      let night = input.softness;
      let body = 1.0 - smoothstep(0.035, 0.11, length(input.local * vec2f(1.0, 0.65)));
      let halo = exp(-r2 * 6.5) * (1.0 - smoothstep(0.6, 1.0, sqrt(r2)));
      // At day/dusk night is exactly zero: a tiny matte insect, no emissive halo.
      let alpha = body * mix(0.52, 0.95, night) + halo * night * input.color.a * 0.32;
      let color = mix(vec3f(0.20, 0.23, 0.16), input.color.rgb, night);
      return vec4f(color, min(alpha, 1.0));
    }
    
    @vertex fn particleVertex(p: Particle, @builtin(vertex_index) vertex: u32) -> Fragment {
      let corners = array<vec2f, 6>(vec2f(-1.0,-1.0), vec2f(1.0,-1.0), vec2f(-1.0,1.0), vec2f(-1.0,1.0), vec2f(1.0,-1.0), vec2f(1.0,1.0));
      let uv = p.positionSize.xy;
      let depth = p.positionSize.z;
      let kind = p.behavior.x;
      let seed = p.behavior.y;
      let time = scene.screen.z;
      let wind = scene.framing.z;
      let gust = scene.framing.w;
      let dissolution = scene.pointer.w;
      var opacity = p.color.a;
      var pos = uv * vec2f(1672.0, 941.0);
      let flowerBend = sin(time * 0.46) * 4.5 + sin(time * 0.79) * 1.8;
      if (kind < 0.5) {
        let height = pow(clamp((1.08 - uv.y) / 0.6, 0.0, 1.0), 1.7);
        let phase = floor((pos.x + (1.0 - uv.y) * 180.0) / 24.0) * 0.5;
        let sway = sin(time * 0.72 + phase) * 4.0 + sin(time * 0.33 + uv.x * 13.0) * 3.0;
        pos.x += (sway * wind + gust * (13.0 + 5.0 * sin(phase))) * height;
        pos.y += sin(time * 0.52 + phase) * height * wind * 0.8;
      } else if (kind < 2.5) {
        let height = clamp((0.83 - uv.y) / 0.43, 0.0, 1.25);
        let bend = (flowerBend * wind + gust * 16.0) * height * height;
        pos += vec2f(bend, bend * 0.16);
        if (kind < 1.5) {
          let petal = smoothstep(0.339, 0.416, uv.x);
          pos.x += sin(time * 1.12 + uv.y * 18.0) * petal * wind * 1.6;
          pos.y += sin(time * 1.4 + uv.x * 30.0) * petal * wind;
          let loosen = smoothstep(0.6, 1.0, seed) * petal;
          pos += vec2f(100.0 + seed * 80.0, -25.0 - seed * 45.0) * gust * loosen;
        }
      } else {
        let distance = smoothstep(0.375, 0.97, uv.x);
        pos.x += (sin(time * 0.24 + seed * 2.0) * 9.0 + sin(time * 0.035) * 18.0) * wind * distance;
        pos.y += sin(time * 0.5 + seed * 6.28) * (2.0 + distance * 5.0) * wind;
        pos += vec2f(80.0 + 120.0 * distance, -35.0 - 28.0 * seed) * gust;
      }
      let nearPointer = exp(-dot((pos - scene.pointer.xy) / 155.0, (pos - scene.pointer.xy) / 155.0));
      let touch = nearPointer * scene.pointer.z;
      pos += vec2f(touch * 8.0 * depth, -touch * 3.0);
      pos += scene.mood.yz * depth;
      // Sequential, irreversible release: petal edges, heart, then the stem down to
      // its base. Every botanical particle leaves, rather than springing back when
      // the input gust decays. All travel and turbulence remain on the GPU.
      if (kind > 0.5) {
        var release = 0.0;
        if (kind < 1.5) {
          let petalEdge = 1.0 - smoothstep(0.287, 0.411, uv.x);
          release = petalEdge * 4.8 + seed * 2.0;
          let heart = p.color.r - p.color.g < 0.055 && p.color.r < 0.36;
          if (heart) { release = 7.0 + seed * 2.0; }
        } else if (kind < 2.5) {
          release = 8.0 + smoothstep(0.43, 0.8, uv.y) * 5.0 + seed * 1.5;
        } else {
          release = seed * 3.0;
        }
        let age = max(0.0, dissolution - release);
        let lift = smoothstep(0.0, 1.3, age);
        let velocity = 0.65 + p.behavior.z * 0.75;
        let spread = p.behavior.w * 2.0 - 1.0;
        let travel = (27.0 * age + 8.5 * age * age) * velocity;
        let curl = sin(age * 1.5 + seed * 6.283) * (10.0 + age * 4.0) * lift;
        pos.x += travel + curl;
        pos.y += (-21.0 * age - 1.1 * age * age) * velocity
          + spread * age * 12.0 + cos(age * 1.1 + seed * 6.283) * age * 5.0 * lift;
        opacity *= 1.0 - smoothstep(5.0, 9.0, age);
        // Explicit terminal state prevents residual dust at the end of the cycle.
        if (dissolution >= 24.0) { opacity = 0.0; }
        // Reassemble from the breeze: stem first, then petals and airborne flecks.
        // At both endpoints opacity and position join the normal cycle continuously.
        if (scene.lifecycle.x < 1.0) {
          var arrival = 0.25 + seed * 0.14;
          var drift = 1.0;
          if (kind > 1.5 && kind < 2.5) {
            arrival = (1.0 - smoothstep(0.43, 0.8, uv.y)) * 0.25 + seed * 0.07;
            drift = 0.3;
          } else if (kind > 2.5) {
            arrival = 0.48 + seed * 0.16;
          }
          let motion = scene.lifecycle.y;
          let assembled = smoothstep(arrival * motion, mix(1.0, arrival + 0.34, motion), scene.lifecycle.x);
          let distance = (1.0 - assembled) * (1.0 - assembled) * drift * motion;
          pos += vec2f(45.0 + seed * 105.0, -24.0 - p.behavior.z * 48.0) * distance;
          pos.y += sin(assembled * 3.14159) * sin(seed * 6.283) * 18.0 * drift * motion;
          opacity *= smoothstep(0.0, 0.65, assembled);
        }
      }
      let projected = projectMeadow(pos, depth);
      let center = projected.xy * scene.screen.w + scene.framing.xy;
      let corner = corners[vertex];
      let radius = p.positionSize.w * scene.screen.w * projected.z;
      let pixel = center + corner * radius;
      var out: Fragment;
      out.position = vec4f(pixel / scene.screen.xy * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0), 0.2, 1.0);
      out.local = corner;
      out.color = vec4f(light(p.color.rgb, 1.0), opacity);
      out.softness = select(0.64, 0.42, kind > 0.5);
      return out;
    }
    @fragment fn particleFragment(input: Fragment) -> @location(0) vec4f {
      let radius = length(input.local);
      if (radius > 1.0) { discard; }
      let alpha = input.color.a * (1.0 - smoothstep(input.softness, 1.0, radius));
      return vec4f(input.color.rgb, alpha);
    }

The sea remembers

WebGL2 · GLSL · Three.js

5 shaders · 285 lignes

Ouvrir la scène
  • spectrum.glslfragment Adapted from Clearwater — Copyright (c) 2026 Lumaris, MIT. 18 lignes
    Fichier
    src/maree/spectrum.glsl
    Points d’entrée
    main fragment
    Ressources
    3 uniforms
    // Adapted from Clearwater — Copyright (c) 2026 Lumaris, MIT.
    
    uniform sampler2D uH0; uniform float uT, uL;
    vec2 cmul(vec2 a, vec2 b){ return vec2(a.x*b.x-a.y*b.y, a.x*b.y+a.y*b.x); }
    void main(){
      ivec2 id = ivec2(gl_FragCoord.xy);
      vec4 s = texelFetch(uH0, id, 0);
      vec2 n = vec2(id); n -= step(64.0, n) * 128.0;
      vec2 k = 6.28318530718*n/uL; float kl = length(k);
      float w = sqrt(9.81*kl + 7.4e-5*kl*kl*kl);
      // gentle dispersion quantisation keeps the loop seamless over 60 s
      float w0 = 6.28318530718/60.0; w = floor(w/w0)*w0;
      float c = cos(w*uT), sn = sin(w*uT);
      vec2 H = cmul(s.xy, vec2(c,sn)) + cmul(s.zw, vec2(c,-sn));
      vec2 C1 = H - k.x*H;                    // h + i*dh/dx
      vec2 C2 = vec2(-k.y*H.y, k.y*H.x);      // dh/dz
      o = vec4(C1, C2);
    }
  • fft.glslfragment Adapted from Clearwater — Copyright (c) 2026 Lumaris, MIT. 18 lignes
    Fichier
    src/maree/fft.glsl
    Points d’entrée
    main fragment
    Ressources
    3 uniforms
    // Adapted from Clearwater — Copyright (c) 2026 Lumaris, MIT.
    
    uniform sampler2D uSrc; uniform int uP, uHoriz;
    vec2 cmul(vec2 a, vec2 b){ return vec2(a.x*b.x-a.y*b.y, a.x*b.y+a.y*b.x); }
    void main(){
      ivec2 id = ivec2(gl_FragCoord.xy);
      int j = uHoriz==1 ? id.x : id.y;
      int k = j & (uP-1);
      int i = ((j - (j & (2*uP-1))) >> 1) + k;
      bool y1 = (j & uP) != 0;
      ivec2 a = uHoriz==1 ? ivec2(i, id.y) : ivec2(id.x, i);
      ivec2 b = uHoriz==1 ? ivec2(i+64, id.y) : ivec2(id.x, i+64);
      vec4 x0 = texelFetch(uSrc, a, 0), x1 = texelFetch(uSrc, b, 0);
      float ang = 3.14159265359*float(k)/float(uP);
      vec2 w = vec2(cos(ang), sin(ang));
      vec4 wx = vec4(cmul(w,x1.xy), cmul(w,x1.zw));
      o = y1 ? x0-wx : x0+wx;
    }
  • resolve.glslfragment Adapted from Clearwater — Copyright (c) 2026 Lumaris, MIT. 8 lignes
    Fichier
    src/maree/resolve.glsl
    Points d’entrée
    main fragment
    Ressources
    1 uniform
    // Adapted from Clearwater — Copyright (c) 2026 Lumaris, MIT.
    
    uniform sampler2D uSrc;
    void main(){
      vec4 s = texelFetch(uSrc, ivec2(gl_FragCoord.xy), 0);
      vec2 sl = vec2(s.y, s.z);
      o = vec4(s.x, sl, dot(sl,sl));
    }
  • water.frag.glslfragment Fresnel, spectral slope variance and height-field intersection adapted from Clearwater, Copyright (c) 2026 Lumaris (MIT). Shore, night sky and biology added here. 234 lignes
    Fichier
    src/maree/water.frag.glsl
    Points d’entrée
    main fragment
    Ressources
    32 uniforms
    // Fresnel, spectral slope variance and height-field intersection adapted from
    // Clearwater, Copyright (c) 2026 Lumaris (MIT). Shore, night sky and biology added here.
    precision highp float;
    uniform sampler2D uSurface,uClouds,uTrace;
    uniform sampler2D uSandColor,uSandNormal,uSandSurface;
    uniform sampler2D uBoatReflection,uShoreColor,uShoreDepth;
    uniform vec3 uBoatPosition,uCrabPosition,uCrabFeet[8];
    uniform float uCrabVisible,uCrabScale;
    uniform float uBoatVisible,uShoreActive;
    uniform mat4 uInvProjection,uCameraWorld,uViewProjection,uSkyProjection;
    uniform vec3 uCamera,uMoon,uSun,uLight;
    uniform float uTime,uSurfPhase,uHorizonCurve,uCloudReady,uDay,uWarmth,uSunlight,uMoonlight;
    varying vec2 vUv;
    const float PI=3.14159265359;
    float hash(vec2 p){vec3 p3=fract(vec3(p.xyx)*.1031);p3+=dot(p3,p3.yzx+33.33);return fract((p3.x+p3.y)*p3.z);}
    float noise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),mix(hash(i+vec2(0,1)),hash(i+1.),f.x),f.y);}
    float fbm(vec2 p){float v=0.,a=.5;mat2 m=mat2(.8,-.6,.6,.8);for(int i=0;i<5;i++){v+=a*noise(p);p=m*p*2.04+3.7;a*=.5;}return v;}
    float foamCells(vec2 p){
     vec2 id=floor(p),f=fract(p);float d=2.;
     for(int y=-1;y<=1;y++)for(int x=-1;x<=1;x++){
       vec2 o=vec2(float(x),float(y));vec2 center=vec2(hash(id+o),hash(id+o+13.7));
       d=min(d,length(o+center-f));
     }
     return smoothstep(.18,.36,d);
    }
    vec4 cloud(vec3 d){vec4 p=uSkyProjection*vec4(uCamera+d*20000.,1.);vec2 uv=p.xy/p.w*.5+.5;
     if(p.w<=0.||min(uv.x,uv.y)<.005||max(uv.x,uv.y)>.995||uCloudReady<.5)return vec4(0);
     return texture2D(uClouds,uv);}
    vec3 sky(vec3 d, float diskVisible){
     float y=max(d.y,0.);vec3 col=mix(vec3(.021,.034,.052),vec3(.0018,.006,.012),pow(y,.38));
     vec3 daySky=mix(vec3(.48,.70,.88),vec3(.075,.30,.66),pow(y,.45));
     col=mix(col,daySky,uDay);
     float sunFacing=pow(max(dot(d,uSun),0.),5.);
     col+=vec3(.65,.16,.035)*exp(-y*6.)*uWarmth*(.3+.7*sunFacing);
     float mu=max(dot(d,uMoon),0.);float angle=acos(min(mu,1.));
     col+=vec3(.15,.145,.13)*exp(-angle*17.)*.4*uMoonlight;
     float disk=1.-smoothstep(.010,.0118,angle);
     vec2 moonUV=vec2(d.x-uMoon.x,d.y-uMoon.y)/.023;
     float crater=.73+.27*fbm(moonUV*13.);
     col+=vec3(.70,.68,.57)*disk*crater*diskVisible*uMoonlight;
     float sunAngle=acos(clamp(dot(d,uSun),-1.,1.));
     vec3 sunColor=mix(vec3(1.,.91,.72),vec3(1.,.34,.075),uWarmth);
     col+=sunColor*exp(-sunAngle*12.)*.22*uSunlight;
     float sunDisk=1.-smoothstep(.012,.014,sunAngle);
     col+=sunColor*5.*sunDisk*diskVisible*uSunlight;
     // Sparse world-anchored stars. Pixel coverage keeps their tiny disks visible
     // without the subpixel flicker of a sharp procedural spike; no timed blinking.
     vec2 st=vec2(atan(d.x,-d.z),asin(clamp(d.y,-1.,1.)))*120.;vec2 id=floor(st);
     vec2 center=.2+.6*vec2(hash(id+17.3),hash(id+61.7));
     float radius=mix(.055,.09,hash(id+9.2));
     float aa=max(length(fwidth(st))*.5,.025);
     float star=(1.-smoothstep(max(0.,radius-aa),radius+aa,length(fract(st)-center)));
     star*=min(1.,radius*radius/(aa*aa))*step(.991,hash(id));
     float brightness=mix(.20,.65,pow(hash(id+31.4),3.));
     float nightVisibility=(1.-smoothstep(0.,.22,uDay))*smoothstep(.025,.18,y);
     float moonGlare=mix(1.,smoothstep(.025,.14,angle),uMoonlight);
     col+=mix(vec3(.72,.83,1.),vec3(1.,.91,.76),hash(id+42.))*brightness*star*nightVisibility*moonGlare*diskVisible;
     vec4 c=cloud(d);float alpha=clamp(c.a,0.,1.);
     float light=dot(c.rgb,vec3(.21,.72,.07))*(uMoonlight+uSunlight);
     // Takram computes scattering with a directional source. Re-expose it for moonlight.
     vec3 night=vec3(.34,.38,.43)*light*.22+vec3(.004,.008,.014)*alpha;
     vec3 daylightCloud=vec3(.34,.42,.53)*alpha+vec3(1.45,1.38,1.22)*light;
     vec3 dawnCloud=vec3(.35,.09,.035)*alpha+vec3(.8,.3,.09)*light;
     col=col*(1.-alpha)+mix(night,daylightCloud,uDay)+dawnCloud*uWarmth*.55;
    
     return col;
    }
    float tide(){return 1.4+.38*cos(uSurfPhase);}
    // Quixel scan is tileable over 4 metres. Tangents: +X and -Z, normal: +Y.
    vec2 sandUV(vec2 p){return vec2(p.x,-p.y)/4.;}
    // Use a stable height mip inside the ray search; retain full detail for shading.
    float ground(vec2 p){return (p.y-1.4-p.x*.13)*.012+.009*(textureLod(uSandSurface,sandUV(p),2.).b-.5);}
    // Trace the fixed beach independently of the moving water surface. Its 9 mm
    // relief bounds the search around the analytic, sloping base plane.
    vec3 seabedPoint(vec3 origin,vec3 ray){
     float denominator=ray.y-.012*(ray.z-.13*ray.x);
     if(denominator>=-.00001)return origin+ray*50000.;
     float planeT=(.012*(origin.z-1.4-.13*origin.x)-origin.y)/denominator;
     float radius=.0045/-denominator;
     float nearT=max(0.,planeT-radius),farT=max(nearT,planeT+radius);
     for(int i=0;i<8;i++){
       float t=(nearT+farT)*.5;vec3 point=origin+ray*t;
       if(point.y>ground(point.xz))nearT=t;else farT=t;
     }
     return origin+ray*((nearT+farT)*.5);
    }
    // At the central shoreline (z=1.78), each audible surf peak meets a crest.
    float shoreSpace(vec2 p){return p.y*1.5-.48*p.x+.25*sin(p.x*1.3+p.y*.21)+.15*sin(p.x*3.1-p.y*.32);}
    float phase(vec2 p){return shoreSpace(p)-shoreSpace(vec2(0,1.78))+PI*.5-uSurfPhase;}
    float swell(vec2 p){float crest=pow(.5+.5*sin(phase(p)),4.);return .18*crest-.025;}
    vec4 surface(vec2 p){
     mat2 m=mat2(.8,-.6,.6,.8);
     vec4 a=texture2D(uSurface,p/4.6),b=texture2D(uSurface,(m*p)/(4.6*.41)+.37);
     vec2 sl=a.yz+.10*vec2(m[0][0]*b.y+m[0][1]*b.z,m[1][0]*b.y+m[1][1]*b.z);
     vec4 c=texture2D(uSurface,(m*p)/(4.6*.13)+.71);sl+=c.yz*.12;
     float shallow=1.-smoothstep(-4.,3.,p.y);
     sl*=.75+.25*shallow;
     float e=.015;sl+=vec2(swell(p+vec2(e,0))-swell(p-vec2(e,0)),swell(p+vec2(0,e))-swell(p-vec2(0,e)))/(2.*e);
     return vec4((a.x+.041*b.x)*(.5+.5*shallow)+swell(p),sl,max(a.w-dot(a.yz,a.yz),0.));
    }
    float fresnel(float c){float g=sqrt(1.333*1.333+c*c-1.);return .5*pow((g-c)/(g+c),2.)*(1.+pow((c*(g+c)-1.)/(c*(g-c)+1.),2.));}
    vec3 sandNormal(vec2 p,float strength){
     vec3 tangent=texture2D(uSandNormal,sandUV(p)).xyz*2.-1.;
     return normalize(vec3(tangent.x*strength,max(tangent.z,.1),-tangent.y*strength));
    }
    float crabShadow(vec2 p){
     if(uCrabVisible<.5||distance(p,uCrabPosition.xz)>4.75*uCrabScale)return 0.;
     vec2 shadowOffset=-uLight.xz/max(uLight.y,.18)*(.4*uCrabScale);
     vec2 local=(p-uCrabPosition.xz-shadowOffset)/(vec2(.684,.547)*uCrabScale);
     float shadow=exp(-dot(local,local)*1.5)*.4;
     for(int i=0;i<8;i++){
      vec2 foot=(p-uCrabFeet[i].xz)/(.147*uCrabScale);
      shadow=max(shadow,exp(-dot(foot,foot)-max(uCrabFeet[i].y,0.)*90.)*.24);
     }
     return shadow;
    }
    vec3 sand(vec2 p){
     vec3 base=texture2D(uSandColor,sandUV(p)).rgb; // Hardware sRGB -> linear decode.
     float ao=texture2D(uSandSurface,sandUV(p)).g;
     float damp=1.-smoothstep(tide()+.5,tide()+2.8,p.y-p.x*.13);
     vec3 normal=sandNormal(p,mix(.85,.35,damp));
     vec3 light=mix(vec3(.15,.21,.30),vec3(.065,.12,.20),damp);
     light=mix(light,vec3(1.6,1.52,1.3),uDay)+vec3(.45,.18,.065)*uWarmth;
     return base*light*(1.-crabShadow(p))*mix(.65,1.,ao)*(.65+1.8*max(dot(normal,uLight),0.));
    }
    void main(){
     // A very shallow optical bow; water, sky, clouds and reflections share it.
     vec2 ndc=vUv*2.-1.;ndc.y+=uHorizonCurve*ndc.x*ndc.x;
     vec4 v=uInvProjection*vec4(ndc,1.,1.);vec3 rd=normalize((uCameraWorld*vec4(v.xyz/v.w,0.)).xyz);
     if(rd.y>=-.0002){gl_FragColor=vec4(sky(rd,1.),1.);gl_FragDepth=.999999;return;}
     // Bracket the displaced surface, then refine the nearest shore intersection.
     float t=-uCamera.y/rd.y;vec3 P;vec4 s;
     float nearT=max(0.,(-uCamera.y+.24)/rd.y),farT=(-uCamera.y-.16)/rd.y;
     for(int i=0;i<13;i++){
       t=(nearT+farT)*.5;P=uCamera+rd*t;s=surface(P.xz);
       if(P.y>max(s.x,ground(P.xz)))nearT=t;else farT=t;
     }
     t=(nearT+farT)*.5;P=uCamera+rd*t;s=surface(P.xz);
     float g=ground(P.xz),depth=max(s.x-g,0.);float wet=smoothstep(-.015,.025,s.x-g);
     vec3 bed=seabedPoint(uCamera,rd);
     vec3 n=normalize(vec3(-s.y,1.,-s.z));n=normalize(mix(vec3(0,1,0),n,smoothstep(-.02,.12,depth)));
     vec2 micro=vec2(noise(P.xz*75.),noise(P.xz*75.+31.7))-.5;
     n=normalize(n+vec3(micro.x,0.,micro.y)*.20*wet);
     vec3 view=-rd;float nv=max(dot(n,view),.03);vec3 reflection=reflect(rd,n);reflection.y=abs(reflection.y);
     float F=fresnel(nv);vec3 reflected=sky(reflection,0.);
     // The distant sailboat is mirrored below the same optically curved horizon.
     vec2 boatUV=vUv+vec2(n.x,n.z)*.004;
     vec4 boatReflection=texture2D(uBoatReflection,boatUV);
     reflected=mix(reflected,boatReflection.rgb,boatReflection.a*.3*uBoatVisible*(.55+.45*noise(P.xz*13.)));
     vec3 under=mix(vec3(.007,.016,.024),vec3(.012,.13,.17),uDay)*(.65+.35*fbm(P.xz*3.));
     vec3 bedColor=sand(bed.xz);
     under=mix(bedColor,under,1.-exp(-depth*3.));
     vec3 emergedColor=vec3(0);float emergedAlpha=0.;
     // Shore life shares one depth layer: submerged parts receive water and foam;
     // the exposed shell and legs remain above the surface.
     if(uShoreActive>.5){
       vec4 animal=texture2D(uShoreColor,vUv);
       if(animal.a>.001){
         float fd=texture2D(uShoreDepth,vUv).r;
         vec4 fv=uInvProjection*vec4(ndc,fd*2.-1.,1.);
         vec3 animalWorld=(uCameraWorld*vec4(fv.xyz/fv.w,1.)).xyz;
         float submerged=smoothstep(.0,.012,s.x-animalWorld.y);
         float aboveBed=step(ground(animalWorld.xz)-.002,animalWorld.y);
         float inFront=step(length(animalWorld-uCamera),length(bed-uCamera)+.01);
         emergedColor=animal.rgb;
         float isCrab=uCrabVisible*(1.-step(3.2*uCrabScale,distance(animalWorld.xz,uCrabPosition.xz)));
         emergedAlpha=animal.a*(1.-submerged)*aboveBed*inFront*isCrab;
         float path=length(animalWorld-P);
         vec3 transmission=exp(-vec3(.8,.34,.2)*path);
         vec3 animalColor=animal.rgb*transmission+under*(1.-transmission)*.35;
         under=mix(under,animalColor,animal.a*submerged*aboveBed*inFront*wet);
       }
     }
     vec3 col=F*reflected+(1.-F)*under;
     // Direct light follows the active body around the inclined orbit.
     vec3 halfway=normalize(view+uLight);float nh=max(dot(n,halfway),.0001),nl=max(dot(n,uLight),0.);
     float variance=max(s.w,0.);float a2=.0012+variance*1.2;
     float c2=max(nh*nh,.00001);float tangent=(1.-c2)/c2;
     float D=exp(-tangent/a2)/(PI*a2*c2*c2);
     float visibility=.5/(nl*sqrt(nv*nv*(1.-a2)+a2)+nv*sqrt(nl*nl*(1.-a2)+a2)+.0001);
     float lightTransmission=(1.-cloud(uLight).a)*(uMoonlight+uSunlight);
     vec3 sourceColor=mix(vec3(.30,.30,.29),mix(vec3(1.8,1.65,1.35),vec3(1.8,.65,.16),uWarmth),uSunlight);
     col+=sourceColor*min(D*visibility*fresnel(max(dot(halfway,view),.01))*nl,3.)*lightTransmission;
     vec3 beach=bedColor;
     // A rough, water-coated sand layer retains the moon after the surge recedes.
     float damp=1.-smoothstep(tide()+1.,tide()+3.,bed.z-bed.x*.13);
     vec3 sandData=texture2D(uSandSurface,sandUV(bed.xz)).rgb;
     vec3 filmN=sandNormal(bed.xz,mix(.55,.22,damp));
     float filmCos=max(dot(filmN,halfway),.001),filmCos2=filmCos*filmCos;
     float filmRough=mix(.085,.028,damp)*mix(.6,1.4,sandData.r);
     float filmD=exp(-(1.-filmCos2)/(filmCos2*filmRough))/(PI*filmRough*filmCos2*filmCos2);
     float grain=mix(.45,1.,sandData.g)*(.6+.4*sandData.b);
     vec3 film=sourceColor*.4*filmD*fresnel(max(dot(halfway,view),.01))*grain*damp*lightTransmission;
     beach+=film;col+=film*(1.-smoothstep(.0,.12,depth))*.7;
     vec3 flatR=reflect(rd,vec3(0,1,0));beach+=sky(flatR,0.)*.28*(1.-smoothstep(tide()+.5,tide()+2.,bed.z));
     col=mix(beach,col,wet);
     // Fine shore lace, broken by turbulent gaps, moving with the tidal edge.
     float lace=fbm(P.xz*23.+vec2(0,uTime*.06));
     float wave=sin(phase(P.xz)+.35*(fbm(P.xz*8.)-.5));
     float crest=pow(max(wave,0.),13.)*smoothstep(.005,.10,depth);
     float reach=P.z-tide()-P.x*.13-.30*sin(P.x*.8)-.17*sin(P.x*2.7);
     float wash=1.-smoothstep(.025,.13,abs(reach-.12*(lace-.5)));
     float remnant=(1.-smoothstep(.018,.07,abs(reach-.58)))*.45;
     float foam=max(crest, max(wash,remnant));
     float bubbly=foamCells(P.xz*65.+vec2(0,uTime*.15));
     foam*=smoothstep(.25,.63,lace)*(.25+.75*bubbly)*(1.-smoothstep(15.,50.,t));
     col=mix(col,mix(vec3(.065,.087,.108),vec3(.85,.91,.94),uDay)*(0.35+.8*max(dot(n,uLight),0.)+.25*lace),clamp(foam,0.,.88));
     // A faint wake trailing the slow sailboat, never a broad motorboat plume.
     vec2 wakeP=P.xz-uBoatPosition.xz;
     float behind=-wakeP.x-3.;
     float wakeWidth=.25+max(behind,0.)*.055;
     float wakeEdge=1.-smoothstep(.05,.19,abs(abs(wakeP.y)-wakeWidth));
     float wake=step(0.,behind)*(1.-smoothstep(3.,20.,behind))*wakeEdge*uBoatVisible;
     col=mix(col,mix(vec3(.035,.05,.07),vec3(.65,.72,.75),uDay),wake*.11*wet);
     // Persistent fingertip trail in world space, rendered as individual plankton cells.
     vec2 traceUV=(P.xz+vec2(8,10))/16.;float trail=0.;
     if(min(traceUV.x,traceUV.y)>0.&&max(traceUV.x,traceUV.y)<1.)trail=texture2D(uTrace,traceUV).r;
     vec2 cells=(P.xz+vec2(noise(P.xz*6.),noise(P.zx*7.))*.06)*90.;vec2 cid=floor(cells),cf=fract(cells)-.5;
     cf-=vec2(hash(cid),hash(cid+7.2))*.50-.25;
     float footprint=min(fwidth(cells.x)+fwidth(cells.y),2.);
     float dotP=1.-smoothstep(.08,.15+footprint*.28,length(cf));
     float sparseness=step(.89,hash(cid+41.));
     float sparkle=dotP*sparseness*(.65+.35*sin(uTime*2.+hash(cid)*20.));
     // A soft, restrained touch trail; wave-driven plankton keeps its own intensity.
     float touchSparkle=dotP*sparseness*(.8+.2*sin(uTime*.8+hash(cid)*20.));
     float glow=trail*(.022+touchSparkle*3.8)*(.6+.4*fbm(P.xz*16.))+foam*sparkle*2.5*smoothstep(.4,.63,fbm(P.xz*3.));
     glow*=(.4+.6*wet)*(1.-smoothstep(22.,65.,t));
     col+=vec3(.014,.85,.95)*glow*mix(1.,.015,uDay);
     col=mix(col,emergedColor,emergedAlpha);
     col=mix(col,mix(vec3(.009,.019,.033),vec3(.32,.52,.67),uDay)+vec3(.25,.075,.025)*uWarmth,1.-exp(-t*.001));
     col*=1.-.19*dot(vUv-.5,vUv-.5);
     gl_FragColor=vec4(max(col,0.),1.);
     vec4 projected=uViewProjection*vec4(P,1.);gl_FragDepth=clamp(projected.z/projected.w*.5+.5,0.,.999998);
    }
  • post.frag.glslfragment GLSL, sans commentaire d’en-tête. 7 lignes
    Fichier
    src/maree/post.frag.glsl
    Points d’entrée
    main fragment
    Ressources
    3 uniforms
    uniform sampler2D image;uniform vec2 resolution;uniform float daylight;varying vec2 vUv;
    vec3 aces(vec3 x){return clamp((x*(2.51*x+.03))/(x*(2.43*x+.59)+.14),0.,1.);}
    void main(){vec3 col=texture2D(image,vUv).rgb;vec3 bloom=vec3(0);
      for(int i=0;i<12;i++){float a=float(i)*6.2831853/12.;vec2 d=vec2(cos(a),sin(a))/resolution;
        bloom+=max(texture2D(image,vUv+d*5.).rgb-.15,0.)*.035;
        bloom+=max(texture2D(image,vUv+d*14.).rgb-.3,0.)*.022;}
      col=aces((col+bloom*mix(1.,.08,daylight))*mix(1.1,.72,daylight));gl_FragColor=vec4(pow(col,vec3(1./2.2)),1.);}

La nuée

WebGPU · WGSL · vgpu

8 shaders · 796 lignes

Ouvrir la scène
  • common.wgslmodule Shared by every /nuee shader that reads the scene. The TypeScript side prepends the constants generated from murmuration.ts before this chunk. 129 lignes
    Fichier
    src/nuee/common.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    1 liaison
    // Shared by every /nuee shader that reads the scene. The TypeScript side
    // prepends the constants generated from murmuration.ts before this chunk.
    
    struct Scene {
      view: mat4x4f,
      proj: mat4x4f,
      invViewProj: mat4x4f,
      eye: vec4f,        // observer position, w: animation clock (s)
      sun: vec4f,        // unit direction toward the sun, w: elevation (radians)
      zenith: vec4f,     // sky color overhead (linear HDR), w: star visibility (0..1)
      horizon: vec4f,    // sky color at the horizon, w: sun glow strength
      glow: vec4f,       // color of the glow around the sun, w: cloud cover (0..1)
      shadow: vec4f,     // Earth shadow / Belt of Venus tint opposite the sun, w: its strength
      falcon: vec4f,     // falcon position, w: presence (0..1)
      falconVel: vec4f,  // falcon velocity (m/s), w: stoop (0..1)
      flock: vec4f,      // xyz: flock centroid, w: fear radius (m)
      roost: vec4f,      // xyz: roost center in the reeds, w: roost progress (0..1)
      sim: vec4f,        // x: step (s), y: arrival share, z: birds alive, w: motion allowed (0/1)
      viewport: vec4f,   // width and height (px), pixel ratio, exposure
      wind: vec4f,       // x/z: wind direction (unit), y: gust (0..1), w: clock for the reeds
      tick: vec4f,       // x: which half of the flock works out its social forces this step (0/1)
    };
    @group(0) @binding(0) var<uniform> scene: Scene;
    
    const PI: f32 = 3.14159265;
    const TAU: f32 = 6.28318531;
    
    fn pcg(value: u32) -> u32 {
      let state = value * 747796405u + 2891336453u;
      let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
      return (word >> 22u) ^ word;
    }
    fn rand(seed: u32) -> f32 { return f32(pcg(seed)) / 4294967296.0; }
    
    fn hash2(p: vec2f) -> f32 {
      var q = fract(vec3f(p.x, p.y, p.x) * 0.1031);
      q += dot(q, q.yzx + 33.33);
      return fract((q.x + q.y) * q.z);
    }
    
    fn noise2(p: vec2f) -> f32 {
      let i = floor(p);
      let f = fract(p);
      let s = f * f * (3.0 - 2.0 * f);
      return mix(mix(hash2(i), hash2(i + vec2f(1, 0)), s.x), mix(hash2(i + vec2f(0, 1)), hash2(i + vec2f(1, 1)), s.x), s.y);
    }
    
    fn fbm2(p: vec2f, octaves: i32) -> f32 {
      var sum = 0.0;
      var amplitude = 0.5;
      var q = p;
      for (var i = 0; i < octaves; i++) {
        sum += noise2(q) * amplitude;
        q = mat2x2f(1.6, 1.2, -1.2, 1.6) * q + vec2f(3.1, 1.7);
        amplitude *= 0.5;
      }
      return sum;
    }
    
    /**
     * Sky radiance along dir, without the sun's disk (dir.y may be slightly negative
     * near the horizon): what haze and far silhouettes take their color from.
     */
    fn skyLight(dir: vec3f) -> vec3f {
      let up = max(dir.y, 0.0);
      var color = mix(scene.horizon.rgb, scene.zenith.rgb, pow(up, 0.42));
      let mu = dot(dir, scene.sun.xyz);
      // Forward scattering: a wide warm glow that hugs the horizon, and the sun's halo.
      let hug = exp(-up * 7.0);
      color += scene.glow.rgb * scene.horizon.w * (pow(max(mu, 0.0), 6.0) * 0.55 * (0.4 + 0.6 * hug) + pow(max(mu, 0.0), 48.0) * 0.9);
      // Opposite the sun, the Earth's shadow rises as a blue band under the pink Belt of Venus.
      let away = max(-mu, 0.0);
      let band = exp(-pow((up - 0.06) / 0.06, 2.0)) - 0.55 * exp(-pow(up / 0.03, 2.0));
      color += scene.shadow.rgb * scene.shadow.w * band * away;
      return color;
    }
    
    /** Sky radiance along dir, with the sun itself while it is still up. */
    fn skyColor(dir: vec3f) -> vec3f {
      let disk = smoothstep(0.99985, 0.99995, dot(dir, scene.sun.xyz)) * smoothstep(-0.02, 0.01, scene.sun.w);
      return skyLight(dir) + vec3f(1.0, 0.72, 0.42) * disk * 30.0;
    }
    
    /** A thin deck of altocumulus, lit from below by the low sun. */
    fn clouds(dir: vec3f, base: vec3f) -> vec3f {
      if (dir.y <= 0.012 || scene.glow.w < 0.01) { return base; }
      let t = (2200.0 - scene.eye.y) / dir.y;
      let p = (scene.eye.xz + dir.xz * t) / 900.0 + vec2f(scene.eye.w * 0.004, scene.eye.w * 0.0015);
      let shape = fbm2(p, 5);
      let cover = smoothstep(0.5 - 0.22 * scene.glow.w, 0.78, shape) * smoothstep(0.012, 0.09, dir.y);
      let mu = max(dot(normalize(vec3f(dir.x, 0.0, dir.z)), normalize(vec3f(scene.sun.x, 0.0, scene.sun.z))), 0.0);
      // Undersides catch the sun from below the horizon; tops fade into the sky.
      let lit = scene.glow.rgb * (0.35 + 0.9 * mu * mu) * scene.horizon.w * 0.9 + scene.zenith.rgb * 0.6;
      let edge = smoothstep(0.78, 0.55, shape);
      let tone = mix(lit, scene.zenith.rgb * 0.45, 0.35 * (1.0 - edge));
      return mix(base, tone, cover * 0.85);
    }
    
    fn stars(dir: vec3f) -> vec3f {
      if (scene.zenith.w < 0.01 || dir.y < 0.02) { return vec3f(0.0); }
      let cell = floor(dir * 520.0);
      let seed = fract(sin(dot(cell, vec3f(12.9898, 78.233, 37.719))) * 43758.5453);
      if (seed < 0.9965) { return vec3f(0.0); }
      let center = (cell + 0.5) / 520.0;
      let d = length(dir - normalize(center)) * 520.0;
      let twinkle = 0.8 + 0.2 * sin(scene.eye.w * (1.5 + seed * 3.0) + seed * 90.0) * scene.sim.w;
      return vec3f(0.85, 0.9, 1.0) * exp(-d * d * 3.0) * (seed - 0.9965) * 900.0 * scene.zenith.w * twinkle * smoothstep(0.02, 0.2, dir.y);
    }
    
    /** Aerial perspective: distant things dissolve into the horizon sky. */
    fn haze(color: vec3f, distance: f32, dir: vec3f) -> vec3f {
      let t = 1.0 - exp(-distance / 1800.0);
      return mix(color, skyLight(normalize(vec3f(dir.x, 0.02, dir.z))), t);
    }
    
    fn aces(x: vec3f) -> vec3f {
      return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3f(0.0), vec3f(1.0));
    }
    
    /**
     * Scene radiance as the canvas shows it: the eye's dusk exposure, the ACES filmic
     * curve, then sRGB. Every draw writes display values straight to the canvas (the
     * birds and reeds are near-black silhouettes, so blending them there matches
     * blending in linear light), which saves a full-size HDR target and its passes.
     */
    fn display(radiance: vec3f) -> vec3f {
      let c = aces(radiance * scene.viewport.w);
      return select(1.055 * pow(c, vec3f(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3f(0.0031308));
    }
  • grid.wgslmodule Spatial hash rebuilt every step: `clear` empties the counts, `insert` drops each flying bird into its cell's fixed-size bucket, twice: a fine cell for close neighbours and separation, and a wide one (in the same table, salted apart) so that a bird in a sparse part of the flock still finds its seven neighbours, as a starling does at any distance. A full bucket simply refuses more birds. Coarse mass cells only count birds: they tell each bird where the flock lies nearby. 22 lignes
    Fichier
    src/nuee/grid.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    0 liaison
    // Spatial hash rebuilt every step: `clear` empties the counts, `insert` drops each
    // flying bird into its cell's fixed-size bucket, twice: a fine cell for close
    // neighbours and separation, and a wide one (in the same table, salted apart) so
    // that a bird in a sparse part of the flock still finds its seven neighbours, as a
    // starling does at any distance. A full bucket simply refuses more birds. Coarse
    // mass cells only count birds: they tell each bird where the flock lies nearby.
    
    // pos: xyz + wingbeat phase; vel: xyz + bank; aux: alertness, arrival rank, landed, seed;
    // zig: start time, side (±1), strength, pouring into the roost (0/1).
    struct Bird { pos: vec4f, vel: vec4f, aux: vec4f, zig: vec4f };
    
    const WIDE_CELL: f32 = CELL * 6.0;
    const MASS_CELL: f32 = 40.0;
    
    fn cellOf(p: vec3f, size: f32) -> vec3i { return vec3i(floor(p / size)); }
    /** `level` 0: fine cells, 1: wide cells, 2: mass cells. */
    fn hashCell(c: vec3i, level: u32) -> u32 {
      let h = (u32(c.x) * 73856093u) ^ (u32(c.y) * 19349663u) ^ (u32(c.z) * 83492791u) ^ (level * 2654435761u);
      return h & (HASH_SIZE - 1u);
    }
    /** Flying birds only: those still waiting to arrive, or asleep in the reeds, never interact. */
    fn flying(b: Bird) -> bool { return b.aux.y < scene.sim.y && b.aux.z < 0.5; }
  • grid-build.wgslcompute ×2 WGSL, sans commentaire d’en-tête. 26 lignes
    Fichier
    src/nuee/grid-build.wgsl
    Points d’entrée
    clear compute @workgroup_size(256), insert compute @workgroup_size(256)
    Ressources
    4 liaisons
    @group(0) @binding(1) var<storage, read> birds: array<Bird>;
    @group(0) @binding(2) var<storage, read_write> cellCount: array<atomic<u32>>;
    @group(0) @binding(3) var<storage, read_write> cellItems: array<u32>;
    @group(0) @binding(4) var<storage, read_write> points: array<vec4f>;
    
    @compute @workgroup_size(256)
    fn clear(@builtin(global_invocation_id) id: vec3u) {
      if (id.x < HASH_SIZE) { atomicStore(&cellCount[id.x], 0u); }
    }
    
    @compute @workgroup_size(256)
    fn insert(@builtin(global_invocation_id) id: vec3u) {
      let i = id.x;
      if (i >= BIRDS) { return; }
      let b = birds[i];
      if (!flying(b)) { return; }
      // A compact copy of the position: the neighbour scan reads 16 bytes, not the whole bird.
      points[i] = vec4f(b.pos.xyz, 0.0);
      let fine = hashCell(cellOf(b.pos.xyz, CELL), 0u);
      let slot = atomicAdd(&cellCount[fine], 1u);
      if (slot < BUCKET) { cellItems[fine * BUCKET + slot] = i; }
      let wide = hashCell(cellOf(b.pos.xyz, WIDE_CELL), 1u);
      let wideSlot = atomicAdd(&cellCount[wide], 1u);
      if (wideSlot < BUCKET) { cellItems[wide * BUCKET + wideSlot] = i; }
      atomicAdd(&cellCount[hashCell(cellOf(b.pos.xyz, MASS_CELL), 2u)], 1u);
    }
  • flight.wgslcompute One step of the murmuration for every bird, after Hemelrijk & Hildenbrandt's StarDisplay: topological flocking on the seven nearest neighbours (found in the 2×2×2 cells around the bird), cohesion weighted by how peripheral a bird is (so edges stay dense and sharp), a blind angle behind, a roost pull and a preferred height (so the flock flies as a thin pancake), escape from the falcon, and the "zig": a quick roll copied from neighbour to neighbour, the dark waves. 324 lignes
    Fichier
    src/nuee/flight.wgsl
    Points d’entrée
    step compute @workgroup_size(128)
    Ressources
    6 liaisons
    // One step of the murmuration for every bird, after Hemelrijk & Hildenbrandt's
    // StarDisplay: topological flocking on the seven nearest neighbours (found in the
    // 2×2×2 cells around the bird), cohesion weighted by how peripheral a bird is (so
    // edges stay dense and sharp), a blind angle behind, a roost pull and a preferred
    // height (so the flock flies as a thin pancake), escape from the falcon, and the
    // "zig": a quick roll copied from neighbour to neighbour, the dark waves.
    
    @group(0) @binding(1) var<storage, read> birds: array<Bird>;
    @group(0) @binding(2) var<storage, read_write> next: array<Bird>;
    @group(0) @binding(3) var<storage, read_write> cellCount: array<atomic<u32>>;
    @group(0) @binding(4) var<storage, read> cellItems: array<u32>;
    /** Social acceleration (xyz) and edgeness (w), kept between a bird's alternate steps. */
    @group(0) @binding(5) var<storage, read_write> steer: array<vec4f>;
    /** Positions of the flying birds, compact for the neighbour scan (grid-build.wgsl). */
    @group(0) @binding(6) var<storage, read> points: array<vec4f>;
    
    const CRUISE: f32 = 10.5;
    const MIN_SPEED: f32 = 5.0;
    const MAX_SPEED: f32 = 18.0;
    const SEPARATION_RADIUS: f32 = 3.0;
    const GRAVITY: f32 = 9.81;
    /** Forces from StarDisplay (N) divided by a starling's 80 g: accelerations in m/s². */
    const W_SEPARATION: f32 = 12.5;
    const W_ALIGNMENT: f32 = 6.25;
    const W_COHESION: f32 = 12.5;
    const ROOST_PULL: f32 = 0.125;
    const HEIGHT_PULL: f32 = 0.05;
    /** Half-thickness of the flock's layer before birds are pressed back into it. */
    const LAYER: f32 = 14.0;
    /** Toward the nearby mass of the flock, for birds at an edge. */
    const W_MASS: f32 = 7.0;
    /** Beyond this distance from the flock's center, stragglers are called back. */
    const FLOCK_REACH: f32 = 260.0;
    /** How far the flock's center may wander from the roost before it wheels back. */
    const DANCE_RADIUS: f32 = 110.0;
    /** The funnel pouring into the roost: below this height birds leave it for their own spot. */
    const FUNNEL_HEIGHT: f32 = 22.0;
    const FUNNEL_RADIUS: f32 = 30.0;
    const FUNNEL_DROP: f32 = 40.0;
    /** The zig: roll out 0.25 s, back 0.30 s; neighbours copy it ~0.13 s later; 1 s refractory. */
    const ZIG_OUT: f32 = 0.25;
    const ZIG_BACK: f32 = 0.3;
    const ZIG_REACTION: f32 = 0.126;
    const ZIG_REFRACTORY: f32 = 1.55;
    const ZIG_BANK: f32 = 1.05;
    
    /** Where this bird will sleep: spread over the reed bed by its seed. */
    fn roostSpot(seed: f32) -> vec3f {
      let a = seed * 911.7;
      let r = sqrt(fract(seed * 57.31)) * ROOST_RADIUS;
      return scene.roost.xyz + vec3f(cos(a) * r, 1.4, sin(a) * r * 0.55);
    }
    
    /** Extra roll of a zigging bird, by time since its zig started. */
    fn zigRoll(age: f32) -> f32 {
      if (age < 0.0 || age > ZIG_OUT + ZIG_BACK) { return 0.0; }
      if (age < ZIG_OUT) { return sin(age / ZIG_OUT * PI * 0.5); }
      return cos((age - ZIG_OUT) / ZIG_BACK * PI * 0.5);
    }
    
    /** Insert a candidate into the sorted list of nearest neighbours. */
    fn keepNearest(nearest: ptr<function, array<u32, NEIGHBOURS>>, nearestD2: ptr<function, array<f32, NEIGHBOURS>>, found: ptr<function, u32>, j: u32, d2: f32) {
      if (*found == NEIGHBOURS && d2 >= (*nearestD2)[NEIGHBOURS - 1u]) { return; }
      var slot = min(*found, NEIGHBOURS - 1u);
      while (slot > 0u && (*nearestD2)[slot - 1u] > d2) {
        (*nearestD2)[slot] = (*nearestD2)[slot - 1u];
        (*nearest)[slot] = (*nearest)[slot - 1u];
        slot--;
      }
      (*nearestD2)[slot] = d2;
      (*nearest)[slot] = j;
      *found = min(*found + 1u, NEIGHBOURS);
    }
    
    @compute @workgroup_size(128)
    fn step(@builtin(global_invocation_id) id: vec3u) {
      let i = id.x;
      if (i >= BIRDS) { return; }
      var b = birds[i];
      let dt = scene.sim.x;
      if (dt <= 0.0 || b.aux.y >= scene.sim.y) { next[i] = b; return; }
      let t = scene.eye.w;
      let seed = b.aux.w;
      // As the roost progresses, birds leave for the reeds from the underside of the
      // flock first, and a bird among pouring neighbours joins them: the flock drains
      // down in streams rather than one bird at a time.
      let queue = 0.55 * smoothstep(-30.0, 30.0, b.pos.y - scene.flock.y) + 0.45 * seed;
      var pouring = scene.roost.w > 0.0 && (queue < scene.roost.w * 1.1 || b.zig.w > 0.5);
    
      if (b.aux.z > 0.5) {
        if (pouring) { next[i] = b; return; }
        // The evening went back: lift off from the reeds.
        b.aux.z = 0.0;
        b.zig.w = 0.0;
        b.vel = vec4f(normalize(vec3f(rand(i) - 0.5, 1.2, rand(i + 7u) - 0.5)) * 8.0, 0.0);
      }
    
      let p = b.pos.xyz;
      var v = b.vel.xyz;
      let speed = max(length(v), 0.1);
      let f = v / speed;
    
      // The social forces (neighbours, separation, the pull of the flock's mass) are
      // what cost: each bird works them out every other step, on alternate halves of
      // the flock, and reuses them in between, a sixtieth of a second later, far
      // quicker than a starling reacts. Everything else is updated every step.
      var alert = b.aux.x * exp(-dt / 3.0);
      var zig = b.zig;
      let zigAge = t - zig.x;
      var social = vec3f(0.0);
      var edgeness = 1.0;
      if ((i & 1u) == u32(scene.tick.x)) {
        // Seven nearest neighbours, kept sorted by distance: first among the fine cells
        // around the bird (which also give separation), then, if the flock is sparse
        // here, among the wide cells.
        var nearest: array<u32, NEIGHBOURS>;
        var nearestD2: array<f32, NEIGHBOURS>;
        for (var k = 0u; k < NEIGHBOURS; k++) { nearestD2[k] = 1e9; }
        var found = 0u;
        var separation = vec3f(0.0);
        let origin = vec3i(floor(p / CELL - 0.5));
        for (var dz = 0; dz < 2; dz++) {
          for (var dy = 0; dy < 2; dy++) {
            for (var dx = 0; dx < 2; dx++) {
              let h = hashCell(origin + vec3i(dx, dy, dz), 0u);
              let n = min(atomicLoad(&cellCount[h]), BUCKET);
              for (var k = 0u; k < n; k++) {
                let j = cellItems[h * BUCKET + k];
                if (j == i) { continue; }
                let d = points[j].xyz - p;
                let d2 = dot(d, d);
                if (d2 > CELL * CELL * 2.25) { continue; }
                if (d2 < SEPARATION_RADIUS * SEPARATION_RADIUS) {
                  // Gaussian push, strongest inside a wingspan, felt in every direction.
                  separation -= d / max(sqrt(d2), 0.05) * exp(-d2 / 1.2);
                }
                // Cohesion and alignment ignore the 90° blind angle behind the bird.
                if (dot(d, f) < -0.7071 * sqrt(d2)) { continue; }
                keepNearest(&nearest, &nearestD2, &found, j, d2);
              }
            }
          }
        }
        if (found < NEIGHBOURS) {
          let wideOrigin = vec3i(floor(p / WIDE_CELL - 0.5));
          for (var dz = 0; dz < 2; dz++) {
            for (var dy = 0; dy < 2; dy++) {
              for (var dx = 0; dx < 2; dx++) {
                let h = hashCell(wideOrigin + vec3i(dx, dy, dz), 1u);
                let n = min(atomicLoad(&cellCount[h]), BUCKET);
                for (var k = 0u; k < n; k++) {
                  let j = cellItems[h * BUCKET + k];
                  if (j == i) { continue; }
                  let d = points[j].xyz - p;
                  let d2 = dot(d, d);
                  if (d2 > WIDE_CELL * WIDE_CELL * 2.25 || dot(d, f) < -0.7071 * sqrt(d2)) { continue; }
                  var known = false;
                  for (var m = 0u; m < found; m++) { known = known || nearest[m] == j; }
                  if (!known) { keepNearest(&nearest, &nearestD2, &found, j, d2); }
                }
              }
            }
          }
        }
    
        social = separation * W_SEPARATION;
        if (found > 0u) {
          var heading = vec3f(0.0);
          var toward = vec3f(0.0);
          var bearing = vec3f(0.0);
          var copy = vec4f(-1.0, 0.0, 0.0, 0.0);
          var pouringNeighbours = 0u;
          for (var k = 0u; k < found; k++) {
            let other = birds[nearest[k]];
            pouringNeighbours += select(0u, 1u, other.zig.w > 0.5);
            let offset = other.pos.xyz - p;
            heading += normalize(other.vel.xyz + vec3f(1e-4));
            toward += offset;
            bearing += offset / max(length(offset), 1e-3);
            // A neighbour that has just zigged is copied after the reaction time.
            let age = t - other.zig.x;
            if (age > ZIG_REACTION && age < ZIG_REACTION + 0.15 && other.zig.z > copy.z) { copy = other.zig; }
            alert = max(alert, other.aux.x * 0.9);
          }
          // Centrality: 0 deep inside (neighbours all around), 1 at the edge.
          let centrality = length(bearing) / f32(found);
          edgeness = centrality;
          social += (normalize(heading) - f) * W_ALIGNMENT;
          social += normalize(toward + vec3f(1e-5)) * W_COHESION * centrality;
          pouring = pouring || (scene.roost.w > 0.0 && pouringNeighbours >= 5u);
          if (copy.z > 0.05 && zigAge > ZIG_REFRACTORY) {
            // Pulses fade as they travel: each copy rolls a little less.
            zig = vec4f(t, copy.y, copy.z * 0.965, 0.0);
          }
        } else {
          // Alone: head back to the others.
          social += normalize(scene.flock.xyz - p) * W_COHESION * 0.5;
        }
    
        // Seven neighbours do not reach across a flock this large, so birds at an edge
        // (and small groups that broke away) are drawn toward where the flock's mass
        // lies within ~60 m, from the mass cells' counts: edges stay sharp and holes
        // close, while the flock keeps whatever shape it has.
        let massOrigin = vec3i(floor(p / MASS_CELL - 0.5));
        var mass = vec4f(0.0);
        for (var dz = 0; dz < 2; dz++) {
          for (var dy = 0; dy < 2; dy++) {
            for (var dx = 0; dx < 2; dx++) {
              let c = massOrigin + vec3i(dx, dy, dz);
              let n = f32(atomicLoad(&cellCount[hashCell(c, 2u)]));
              mass += vec4f((vec3f(c) + 0.5) * MASS_CELL * n, n);
            }
          }
        }
        let toMass = mass.xyz / max(mass.w, 1.0) - p;
        social += normalize(toMass + vec3f(1e-4)) * W_MASS * edgeness * smoothstep(4.0, 24.0, length(toMass));
        steer[i] = vec4f(social, edgeness);
      } else {
        let kept = steer[i];
        social = kept.xyz;
        edgeness = kept.w;
      }
      var accel = social;
      // Stragglers far out are called back to the whole flock.
      let toFlock = scene.flock.xyz - p;
      let apart = length(toFlock);
      accel += toFlock / max(apart, 1.0) * clamp((apart - FLOCK_REACH) / FLOCK_REACH, 0.0, 1.0) * 4.0;
      // Held over the roost: once the flock's center leaves the dance area, the whole
      // flock wheels back as one body. The pull mostly points from the flock's center
      // to the roost, the same for every bird (a pull toward the roost from each
      // bird's own place would make them mill in a ring); the rest is each bird's
      // own, so birds on the outside of the turn swing wide and the flock folds.
      let roostFlat = scene.roost.xz - p.xz;
      let away = length(roostFlat);
      let flockToRoost = scene.roost.xz - scene.flock.xz;
      let flockAway = length(flockToRoost);
      // Once they start to roost, the flock stays right over the reeds.
      let dance = DANCE_RADIUS * (1.0 - 0.7 * smoothstep(0.0, 0.3, scene.roost.w));
      let wheel = normalize(0.7 * flockToRoost / max(flockAway, 1.0) + 0.3 * roostFlat / max(away, 1.0) + vec2f(1e-4, 0.0));
      let pull = wheel * min(max(flockAway - dance, 0.0) * ROOST_PULL, 7.0)
        + roostFlat / max(away, 1.0) * min(max(away - 2.5 * DANCE_RADIUS, 0.0) * ROOST_PULL, 7.0);
      accel += vec3f(pull.x, 0.0, pull.y);
      // A preferred height drifts through the evening. The whole flock drifts toward it
      // (the same push for every bird, so the shape is kept), while birds straying above or below the others are pressed back into
      // a layer a few tens of meters thick: the pancake that folds into ribbons.
      // Going to roost, the flock comes lower over the reeds.
      let preferred = mix(FLOCK_ALTITUDE + 30.0 * sin(t * 0.045 + 1.3), 50.0, smoothstep(0.0, 0.4, scene.roost.w));
      let layer = scene.flock.y - p.y;
      accel.y += clamp((preferred - scene.flock.y) * HEIGHT_PULL, -2.0, 2.0)
        + sign(layer) * max(abs(layer) - LAYER, 0.0) * 0.12 + max(35.0 - p.y, 0.0) * 0.6;
    
      // The falcon: flee (a burst outward when it stoops close), and start a zig.
      let fromFalcon = p - scene.falcon.xyz;
      let falconDistance = length(fromFalcon);
      let fearRadius = scene.flock.w;
      let right = normalize(cross(f, vec3f(0.0, 1.0, 0.0)) + vec3f(1e-4, 0.0, 0.0));
      if (scene.falcon.w > 0.01 && falconDistance < fearRadius) {
        let urgency = 1.0 - falconDistance / fearRadius;
        let flee = fromFalcon / max(falconDistance, 0.5);
        accel += flee * urgency * urgency * (40.0 + 60.0 * scene.falconVel.w) * scene.falcon.w;
        alert = max(alert, urgency * scene.falcon.w);
        if (zigAge > ZIG_REFRACTORY && urgency > 0.25) {
          zig = vec4f(t, select(-1.0, 1.0, dot(flee, right) > 0.0), 0.6 + 0.4 * urgency, 0.0);
        }
      }
    
      var desired = v;
      if (pouring) {
        // Pouring down into the reeds: down a funnel that spins above the roost, then,
        // low over the reeds, gliding steeply down to each bird's own spot.
        if (p.y < FUNNEL_HEIGHT) {
          let toSpot = (roostSpot(seed) - p).xz;
          let across = toSpot / max(length(toSpot), 1.0) * min(length(toSpot) * 0.4, CRUISE);
          desired = vec3f(across.x, -max(p.y * 0.8, 3.0), across.y);
        } else {
          // Toward a point on the funnel's axis well below: a converging, spinning cone.
          let axis = p.xz - scene.roost.xz;
          let r = max(length(axis), 1.0);
          let below = vec3f(scene.roost.x, max(p.y - FUNNEL_DROP, FUNNEL_HEIGHT * 0.5), scene.roost.z);
          let around = vec2f(-axis.y, axis.x) / r * 5.0 * smoothstep(FUNNEL_RADIUS * 3.0, FUNNEL_RADIUS, r);
          desired = normalize(below - p) * 18.0 + vec3f(around.x, 0.0, around.y);
        }
        // High up they still fly as a flock; near the reeds each finds its own way.
        accel = accel * select(0.15, 0.5, p.y >= FUNNEL_HEIGHT) + (desired - v) * 2.2;
        // Down in the reeds: it settles where it touched down.
        if (p.y < 2.5) {
          b.pos = vec4f(p.x, 1.4, p.z, b.pos.w);
          b.vel = vec4f(0.0);
          b.aux.x = 0.0;
          b.aux.z = 1.0;
          b.zig = vec4f(-10.0, 0.0, 0.0, 1.0);
          next[i] = b;
          return;
        }
      }
    
      // A little independent noise.
      accel += (vec3f(hash2(vec2f(f32(i), t)), hash2(vec2f(t, f32(i) * 1.7)), hash2(vec2f(f32(i) * 0.3, t * 1.9))) - 0.5) * 1.2;
    
      // Turning is banking: lateral acceleration is limited; speed relaxes to cruise (τ = 1 s).
      let along = dot(accel, f);
      var lateral = accel - f * along;
      let lateralMag = length(lateral);
      let maxTurn = 18.0 + 22.0 * alert + select(0.0, 14.0, pouring);
      if (lateralMag > maxTurn) { lateral *= maxTurn / lateralMag; }
      // Commuting in from the fields, they fly faster than in the dance.
      let commuting = clamp((apart - 90.0) / 200.0, 0.0, 1.0);
      var cruise = CRUISE * (1.0 + 0.3 * alert + 0.45 * commuting);
      if (pouring) {
        // Fast down the funnel, slowing over the reeds to drop in.
        cruise = clamp(length(desired), 6.0, MAX_SPEED * 1.3);
      }
      v += (lateral + f * (along * 0.2 + (cruise - speed))) * dt;
      v = normalize(v) * clamp(length(v), MIN_SPEED, MAX_SPEED * select(1.0, 1.4, pouring));
    
      let bank = atan2(dot(lateral, right), GRAVITY) + zig.y * zig.z * ZIG_BANK * zigRoll(t - zig.x);
      b.vel = vec4f(v, mix(b.vel.w, bank, 1.0 - exp(-dt * 14.0)));
      // Wingbeats at ~13 Hz in bursts; glides between them.
      let flapping = sin(t * 0.8 + seed * 40.0) > -0.3 || alert > 0.3 || pouring;
      b.pos = vec4f(p + v * dt, b.pos.w + dt * select(1.0, 13.3 * TAU, flapping));
      b.aux.x = alert;
      b.zig = vec4f(zig.xyz, select(0.0, 1.0, pouring));
      next[i] = b;
    }
  • stats.wgslcompute A single workgroup summarizes the flock for the camera, the falcon and the score: centroid of flying birds, how many fly or sleep, their alarm and banking, and how many pass within 250 m of the observer. Read back a few times per second. 41 lignes
    Fichier
    src/nuee/stats.wgsl
    Points d’entrée
    summarize compute @workgroup_size(256)
    Ressources
    2 liaisons
    // A single workgroup summarizes the flock for the camera, the falcon and the
    // score: centroid of flying birds, how many fly or sleep, their alarm and banking,
    // and how many pass within 250 m of the observer. Read back a few times per second.
    
    @group(0) @binding(1) var<storage, read> birds: array<Bird>;
    @group(0) @binding(2) var<storage, read_write> stats: array<vec4f, 3>;
    
    var<workgroup> sums: array<vec4f, 256>;
    var<workgroup> extra: array<vec4f, 256>;
    
    @compute @workgroup_size(256)
    fn summarize(@builtin(local_invocation_index) lane: u32) {
      var position = vec4f(0.0);
      var state = vec4f(0.0);
      for (var i = lane; i < BIRDS; i += 256u) {
        let b = birds[i];
        if (b.aux.y >= scene.sim.y) { continue; }
        if (b.aux.z > 0.5) { state.x += 1.0; continue; }
        position += vec4f(b.pos.xyz, 1.0);
        state.y += b.aux.x;
        state.z += abs(b.vel.w);
        state.w += select(0.0, 1.0, distance(b.pos.xyz, scene.eye.xyz) < 250.0);
      }
      sums[lane] = position;
      extra[lane] = state;
      workgroupBarrier();
      for (var stride = 128u; stride > 0u; stride >>= 1u) {
        if (lane < stride) {
          sums[lane] += sums[lane + stride];
          extra[lane] += extra[lane + stride];
        }
        workgroupBarrier();
      }
      if (lane == 0u) {
        let flying = max(sums[0].w, 1.0);
        // centroid + flying count; landed, mean alarm, mean |bank|, birds near the observer.
        stats[0] = vec4f(sums[0].xyz / flying, sums[0].w);
        stats[1] = vec4f(extra[0].x, extra[0].y / flying, extra[0].z / flying, extra[0].w);
        stats[2] = vec4f(scene.eye.w, 0.0, 0.0, 0.0);
      }
    }
  • sky.wgslfragment The evening all around the observer: sky, clouds and stars above a far treeline, and below it the marsh, where pools mirror the sky between dark reed beds. 68 lignes
    Fichier
    src/nuee/sky.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    0 liaison
    // The evening all around the observer: sky, clouds and stars above a far treeline,
    // and below it the marsh, where pools mirror the sky between dark reed beds.
    
    fn rayDirection(uv: vec2f) -> vec3f {
      let far = scene.invViewProj * vec4f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 1.0, 1.0);
      return normalize(far.xyz / far.w - scene.eye.xyz);
    }
    
    fn skyAbove(dir: vec3f) -> vec3f {
      return clouds(dir, skyColor(dir)) + stars(dir);
    }
    
    /** Height of the distant treeline and dunes above the horizon, as an elevation (radians). */
    fn ridge(azimuth: f32) -> f32 {
      let hills = 0.006 + 0.012 * noise2(vec2f(azimuth * 3.0, 1.7)) + 0.006 * noise2(vec2f(azimuth * 9.0, 4.2));
      let trees = 0.007 * smoothstep(0.35, 0.7, noise2(vec2f(azimuth * 2.2, 8.1))) * (0.6 + 0.4 * noise2(vec2f(azimuth * 140.0, 2.0)));
      return hills + trees;
    }
    
    /** The low light reaching the marsh: sky ambient plus a warm grazing sun. */
    fn marshLight() -> vec3f {
      return scene.zenith.rgb * 0.55 + scene.horizon.rgb * 0.25 + scene.glow.rgb * scene.horizon.w * 0.08;
    }
    
    fn dither(pixel: vec2f, frame: f32) -> f32 {
      return fract(52.9829189 * fract(dot(pixel + frame * vec2f(5.588238, 5.588238), vec2f(0.06711056, 0.00583715))));
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let dir = rayDirection(uv);
      let azimuth = atan2(dir.x, -dir.z);
      let top = ridge(azimuth);
      var color: vec3f;
      if (dir.y > top) {
        color = skyAbove(dir);
      } else if (dir.y > 0.0) {
        // Far trees and dunes: a hazy silhouette just darker than the horizon.
        let silhouette = marshLight() * 0.18;
        color = mix(silhouette, skyLight(normalize(vec3f(dir.x, 0.01, dir.z))), 0.55 + 0.25 * (1.0 - dir.y / max(top, 1e-4)));
      } else {
        let distance = scene.eye.y / max(-dir.y, 1e-5);
        let p = scene.eye.xz + dir.xz * distance;
        // Reed beds: dark, warmer where their tops catch the last light.
        let tops = noise2(p * 0.9) * 0.5 + noise2(p * 0.23) * 0.5;
        color = marshLight() * (0.12 + 0.1 * tops) * mix(0.3, 1.0, smoothstep(6.0, 90.0, distance));
        // Pools and channels between them; standing in the reeds, the nearest are hidden.
        let near = smoothstep(18.0, 45.0, distance);
        if (near > 0.0) {
          let pools = fbm2(p * 0.011 + vec2f(2.3, 7.1), 4);
          let channel = abs(fbm2(p * 0.004 + vec2f(9.0, 3.0), 3) - 0.5);
          // Channels are narrow and half hidden by the reeds.
          let water = max(smoothstep(0.58, 0.61, pools), 0.45 * smoothstep(0.035, 0.02, channel)) * near;
          if (water > 0.0) {
            // A breath of wind shivers the reflection.
            let ripple = vec2f(noise2(p * 0.6 + scene.wind.w * vec2f(0.7, 0.4)), noise2(p * 0.6 + vec2f(5.0) - scene.wind.w * 0.5)) - 0.5;
            let mirrored = normalize(vec3f(dir.x + ripple.x * 0.02, -dir.y, dir.z + ripple.y * 0.02));
            let fresnel = 0.25 + 0.7 * pow(1.0 - clamp(-dir.y, 0.0, 1.0), 5.0);
            color = mix(color, skyAbove(mirrored) * fresnel * 0.85, water);
          }
        }
        color = haze(color, distance, dir);
      }
      // A soft vignette, the display curve, and a little noise against banding in the gradient.
      let centered = uv - 0.5;
      color *= 1.0 - 0.28 * smoothstep(0.25, 0.85, dot(centered, centered) * 2.0);
      let noise = (dither(position.xy, fract(scene.eye.w * 7.0) * 64.0) + dither(position.yx + 17.0, 3.0) - 1.0) / 255.0;
      return vec4f(display(color) + noise, 1.0);
    }
  • birds.wgslvertexfragment Starlings as seen from the ground: dark flapping silhouettes that bank into their turns. Far birds shrink below a pixel, so they become soft dots whose opacity is their coverage: dense parts of the flock read darker, as in life. The last instance is the falcon, larger, with long pointed wings. 107 lignes
    Fichier
    src/nuee/birds.wgsl
    Points d’entrée
    vs_bird vertex, fs_bird fragment
    Ressources
    1 liaison
    // Starlings as seen from the ground: dark flapping silhouettes that bank into
    // their turns. Far birds shrink below a pixel, so they become soft dots whose
    // opacity is their coverage: dense parts of the flock read darker, as in life.
    // The last instance is the falcon, larger, with long pointed wings.
    
    @group(0) @binding(1) var<storage, read> birds: array<Bird>;
    
    struct Silhouette {
      @builtin(position) position: vec4f,
      @location(0) color: vec3f,
      @location(1) alpha: f32,
      @location(2) corner: vec2f, // dots only: position inside the dot (-1..1); wings: 0
    };
    
    fn hidden() -> Silhouette {
      var out: Silhouette;
      out.position = vec4f(2.0, 2.0, 2.0, 1.0);
      out.color = vec3f(0.0);
      out.alpha = 0.0;
      out.corner = vec2f(0.0);
      return out;
    }
    
    @vertex fn vs_bird(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Silhouette {
      let isFalcon = instance >= BIRDS;
      var p: vec3f;
      var v: vec3f;
      var bank: f32;
      var phase: f32;
      var span: f32;
      if (isFalcon) {
        if (scene.falcon.w < 0.01) { return hidden(); }
        p = scene.falcon.xyz;
        v = scene.falconVel.xyz;
        bank = 0.25 * sin(scene.eye.w * 0.8);
        phase = scene.eye.w * mix(28.0, 3.0, scene.falconVel.w);
        // Drawn larger than a real peregrine (1 m) so the hunter can be followed from the reeds.
        span = 2.4;
      } else {
        let b = birds[instance];
        if (b.aux.y >= scene.sim.y || b.aux.z > 0.5) { return hidden(); }
        p = b.pos.xyz;
        v = b.vel.xyz;
        bank = b.vel.w;
        phase = b.pos.w;
        span = 0.39;
      }
    
      let toBird = p - scene.eye.xyz;
      let distance = length(toBird);
      let clip = scene.proj * scene.view * vec4f(p, 1.0);
      if (clip.w < 0.5) { return hidden(); }
      let pixels = span * scene.proj[1][1] * 0.5 * scene.viewport.y / clip.w;
      // Starlings darken the sky; distance lightens them toward the horizon's color.
      let far = 1.0 - exp(-distance / 1400.0);
      let color = display(mix(vec3f(0.012, 0.01, 0.014), scene.horizon.rgb * 0.55, far * 0.7));
    
      // Wing orientation: banked about the flight direction.
      let forward = normalize(v + vec3f(1e-4, 0.0, 0.0));
      let level = normalize(cross(forward, vec3f(0.0, 1.0, 0.0)) + vec3f(0.0, 0.0, 1e-4));
      let right = level * cos(bank) + cross(forward, level) * sin(bank);
      let up = cross(right, forward);
    
      var out: Silhouette;
      if (pixels < 2.5 && !isFalcon) {
        // A soft dot, sized to at least a pixel, carrying the bird's true coverage.
        let corners = array<vec2f, 6>(vec2f(-1, -1), vec2f(1, -1), vec2f(-1, 1), vec2f(-1, 1), vec2f(1, -1), vec2f(1, 1));
        let corner = corners[vertex % 6u];
        let size = max(pixels * 0.45, 0.8 * scene.viewport.z);
        // Coverage follows the wing area we see: a roll that turns the wings toward us
        // darkens the sky there, and that is what the agitation waves are.
        let facing = abs(dot(up, toBird / distance));
        // (Denser than true coverage, so the thick of the flock reads black as the eye sees it.)
        let coverage = min(1.0, pixels * pixels * 0.66 / (size * size) * (0.25 + 0.95 * facing));
        out.position = vec4f(clip.xy + corner * size * 2.0 / scene.viewport.xy * clip.w, clip.z, clip.w);
        out.color = color;
        out.alpha = coverage * 0.85;
        out.corner = corner;
        return out;
      }
    
      // Two wing triangles around the body, flapping and banked.
      let flap = sin(phase) * 0.45;
      let sweep = select(0.05, 0.18, isFalcon);
      let side = select(-1.0, 1.0, vertex >= 3u);
      let tip = right * side * span * 0.5 - forward * span * sweep + up * flap * span * 0.5;
      let nose = forward * span * select(0.28, 0.35, isFalcon);
      let tail = -forward * span * select(0.3, 0.42, isFalcon);
      var local: vec3f;
      switch (vertex % 3u) {
        case 0u: { local = nose; }
        case 1u: { local = tail; }
        default: { local = tip; }
      }
      out.position = scene.proj * scene.view * vec4f(p + local, 1.0);
      out.color = select(color, display(vec3f(0.006, 0.005, 0.007)), isFalcon);
      out.alpha = 0.95;
      out.corner = vec2f(0.0);
      return out;
    }
    
    @fragment fn fs_bird(input: Silhouette) -> @location(0) vec4f {
      let r2 = dot(input.corner, input.corner);
      if (r2 > 1.0) { discard; }
      let a = input.alpha * (1.0 - r2 * select(0.0, 0.6, r2 > 0.0));
      return vec4f(input.color * a, a);
    }
  • reeds.wgslvertexfragment Common reeds (Phragmites) around the observer: thin stems bowing in the wind, each crowned by a drooping plume, dark against the sky and rim-lit toward the sun. 79 lignes
    Fichier
    src/nuee/reeds.wgsl
    Points d’entrée
    vs_reed vertex, fs_reed fragment
    Ressources
    0 liaison
    // Common reeds (Phragmites) around the observer: thin stems bowing in the wind,
    // each crowned by a drooping plume, dark against the sky and rim-lit toward the sun.
    
    /** Each reed draws its stem's segments, then the plume: 3 × 6 + 6 vertices. */
    const STEM_SEGMENTS: u32 = 3u;
    
    struct Reed {
      @builtin(position) position: vec4f,
      @location(0) color: vec3f,
      @location(1) shape: vec2f, // x: across (-1..1), y: along the plume (0..1, stems: -1)
    };
    
    @vertex fn vs_reed(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Reed {
      let seed = instance * 7u;
      let angle = rand(seed) * TAU;
      // A quarter stand close by and taller than the eye, so their plumes break the
      // horizon along the bottom of the view; the rest make the bed, mostly below it.
      let near = instance % 6u == 0u;
      let radius = select(8.0 + 90.0 * pow(rand(seed + 1u), 0.9), 5.0 + 12.0 * rand(seed + 1u), near);
      let base = vec3f(scene.eye.x + cos(angle) * radius, 0.0, scene.eye.z + sin(angle) * radius);
      let height = select(1.0 + 0.7 * rand(seed + 2u), 1.8 + 0.7 * rand(seed + 2u), near);
      let phase = rand(seed + 3u) * TAU;
      let wind = vec3f(scene.wind.x, 0.0, scene.wind.z);
      // Each reed leans its own way a little, besides bowing with the wind.
      let tilt = vec3f(rand(seed + 6u) - 0.5, 0.0, fract(rand(seed + 6u) * 7.31) - 0.5) * 0.3;
      let t = scene.wind.w;
      // Bend grows with the square of height; gusts travel across the bed.
      let sway = (0.12 + 0.07 * sin(t * 1.3 + phase) + 0.05 * sin(t * 3.1 + phase * 2.0)
        + scene.wind.y * 0.25 * (0.6 + 0.4 * sin(t * 0.7 - dot(base.xz, scene.wind.xz) * 0.08))) * height;
    
      let corner = vertex % 6u;
      let across = select(-1.0, 1.0, corner == 1u || corner == 4u || corner == 5u);
      let along = select(0.0, 1.0, corner == 2u || corner == 3u || corner == 5u);
      let toEye = normalize(scene.eye.xyz - base);
      let side = normalize(cross(vec3f(0.0, 1.0, 0.0), toEye));
    
      var p: vec3f;
      var shape: vec2f;
      if (vertex < STEM_SEGMENTS * 6u) {
        // The stem, in a few segments so its bow shows.
        let s = (f32(vertex / 6u) + along) / f32(STEM_SEGMENTS);
        p = base + vec3f(0.0, height * s, 0.0) + (wind * sway + tilt * height) * s * s + side * across * 0.008 * (1.2 - s * 0.6);
        shape = vec2f(across, -1.0);
      } else {
        // The plume droops from the tip, downwind; the fragment carves its feathery
        // spindle. A third of the reeds have not flowered.
        if (rand(seed + 5u) < 0.33) { var none: Reed; none.position = vec4f(2.0, 2.0, 2.0, 1.0); return none; }
        let tip = base + vec3f(0.0, height, 0.0) + wind * sway + tilt * height;
        let lean = normalize(wind * (0.7 + 0.3 * sin(t * 2.0 + phase)) + vec3f(0.0, -1.0, 0.0));
        let plume = select(0.24, 0.34, near);
        p = tip + lean * plume * along + side * across * plume * 0.2 * (0.7 + 0.3 * rand(seed + 4u));
        shape = vec2f(across, along);
      }
    
      // Silhouettes, with a warm rim where the reed stands between us and the low sun.
      let view = normalize(p - scene.eye.xyz);
      let backlit = pow(max(dot(view, scene.sun.xyz), 0.0), 5.0) * scene.horizon.w;
      let ambient = scene.zenith.rgb * 0.02 + scene.horizon.rgb * 0.012;
      var color = ambient * (0.4 + 0.4 * rand(seed + 5u)) + scene.glow.rgb * backlit * select(0.03, 0.05, vertex >= STEM_SEGMENTS * 6u);
    
      var out: Reed;
      out.position = scene.proj * scene.view * vec4f(p, 1.0);
      out.color = display(color);
      out.shape = shape;
      return out;
    }
    
    @fragment fn fs_reed(input: Reed) -> @location(0) vec4f {
      var alpha = 1.0;
      if (input.shape.y >= 0.0) {
        // Feathery plume: a spindle, widest a third of the way down, soft at its edges.
        let y = input.shape.y;
        let width = pow(sin(PI * clamp(0.06 + y * 0.94, 0.0, 1.0)), 0.8) * (1.0 - 0.35 * y);
        // Frayed into strands along its length.
        let strands = 0.55 + 0.45 * noise2(vec2f(input.shape.x * 3.0, y * 14.0));
        alpha = smoothstep(1.0, 0.25, abs(input.shape.x) / max(width, 1e-3)) * strands * 0.85;
      }
      return vec4f(input.color * alpha, alpha);
    }

Ce qui flotte

WebGPU · WGSL · vgpu

14 shaders · 968 lignes

Ouvrir la scène
  • common.wgslmodule Shared by every /meduse shader that reads the scene. The TypeScript side prepends the constants generated from jelly-geometry.ts before this chunk. 158 lignes
    Fichier
    src/jelly/common.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    1 liaison
    // Shared by every /meduse shader that reads the scene. The TypeScript side
    // prepends the constants generated from jelly-geometry.ts before this chunk.
    
    struct Scene {
      view: mat4x4f,
      proj: mat4x4f,
      invViewProj: mat4x4f,
      eye: vec4f,           // camera position, w: animation clock (s)
      body: vec4f,          // bell origin in the world, w: disturbance glow (0..1)
      motion: vec4f,        // x/y: bell tilt (rad), z/w: where the bell was disturbed (u, v)
      pulse: vec4f,         // x: stroke phase (cycles), y: amplitude, z: pacemaker (turns), w: seconds since the disturbance
      depth: vec4f,         // x: meters below the surface, y: surface height (world), z: caustic strength, w: shaft strength
      sunDir: vec4f,        // toward the sun inside the water, w: direct sunlight scale
      ambient: vec4f,       // downwelling light (linear HDR), w: view extinction per unit
      horizon: vec4f,       // open water looking sideways, w: bioluminescence visibility
      abyss: vec4f,         // open water looking down, w: bloom strength
      pointerOrigin: vec4f, // xyz: pointer ray origin, w: current strength (0..1)
      pointerDir: vec4f,    // xyz: pointer ray direction, w: current radius
      pointerVel: vec4f,    // xyz: pointer velocity (units/s), w: simulation step (s)
      viewport: vec4f,      // width and height (px), pixel ratio, motion allowed (0/1)
      frame: vec4f,         // xyz: camera target, the center motes wrap around; w: box half-size
      flow: vec4f,          // x: water rising past us (units/s), y: flow clock, z: exposure, w: bell closing rate (1/s)
    };
    @group(0) @binding(0) var<uniform> scene: Scene;
    
    const PI: f32 = 3.14159265;
    const TAU: f32 = 6.28318531;
    const BELL_HEIGHT: f32 = 0.8;
    /** Stroke lag of the passive rim behind the apex, and around the bell from the pacemaker. */
    const RIM_LAG: f32 = 0.2;
    const PACE_LAG: f32 = 0.04;
    /** Jellyfish emit between 442 and 491 nm; Pelagia's flashes read as this blue. */
    const BIO_BLUE = vec3f(0.02, 0.38, 1.0);
    const BIO_CYAN = vec3f(0.05, 0.7, 1.0);
    
    fn pcg(value: u32) -> u32 {
      let state = value * 747796405u + 2891336453u;
      let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
      return (word >> 22u) ^ word;
    }
    fn rand(seed: u32) -> f32 { return f32(pcg(seed)) / 4294967296.0; }
    
    fn hash3(p: vec3f) -> f32 {
      var q = fract(p * 0.1031);
      q += dot(q, q.zyx + 31.32);
      return fract((q.x + q.y) * q.z);
    }
    
    /** Circular distance between two angles given in turns, in 0..0.5. */
    fn turnDistance(a: f32, b: f32) -> f32 {
      let d = fract(a - b);
      return min(d, 1.0 - d);
    }
    
    /** Mirror of `contractionAt` in jelly-pulse.ts: sine closing, damped recoil, coast. */
    fn contractionCurve(phase: f32) -> f32 {
      let x = fract(phase);
      if (x < 0.24) {
        let s = sin(PI * 0.5 * x / 0.24);
        return s * s;
      }
      let y = (x - 0.24) / 0.45;
      if (y >= 1.0) { return 0.0; }
      let end = exp(-4.0) * 5.0;
      return (exp(-4.0 * y) * (1.0 + 4.0 * y) - end) / (1.0 - end);
    }
    
    /** Contraction at (u, v): the stroke spreads from the pacemaker and the rim trails the apex. */
    fn contractionAt(u: f32, v: f32) -> f32 {
      let lag = RIM_LAG * pow(clamp(v, 0.0, 1.0), 1.6) + PACE_LAG * 2.0 * turnDistance(u, scene.pulse.z);
      return contractionCurve(scene.pulse.x - lag) * scene.pulse.y;
    }
    
    /** Bell profile (radius, height) at polar coordinate v (0 apex, 1 rim) and contraction c. */
    fn bellProfile(v: f32, c: f32) -> vec2f {
      let a = v * BELL_ARC;
      var r = sin(a) / sin(BELL_ARC);
      var y = BELL_HEIGHT * (cos(a) - cos(BELL_ARC)) / (1.0 - cos(BELL_ARC));
      // Rowing stroke: the rim closes to 0.75 of its width while the bell grows 25% taller.
      let rim = v * v;
      r *= 1.0 - c * (0.21 * rim + 0.04);
      y += c * (0.06 * (1.0 - v) - 0.14 * rim);
      // The passive flap flares out when relaxed and tucks under as it closes.
      let flap = smoothstep(0.83, 1.0, v);
      r += flap * (0.05 - 0.08 * c) * (v - 0.83) * 6.0;
      y += flap * (0.015 - 0.05 * c);
      return vec2f(r, y);
    }
    
    /** Bell point in body space; `inner` (0..1) moves through the mesoglea to the subumbrella. */
    fn bellLocal(u: f32, v: f32, inner: f32) -> vec3f {
      var p = bellProfile(v, contractionAt(u, v));
      let angle = u * TAU;
      let t = scene.eye.w;
      // Sixteen lappets: the rim recedes into a notch between each pair of lobes.
      let lobe = sqrt(abs(sin(PI * u * f32(LAPPETS))));
      let notch = smoothstep(0.9, 1.0, v) * (1.0 - lobe);
      p.x -= notch * 0.06;
      p.y += notch * 0.035;
      // Slow asymmetric swelling keeps the dome from reading as a perfect solid.
      let swell = 1.0 + v * (0.014 * sin(angle * 3.0 + t * 0.37) + 0.009 * sin(angle * 5.0 - t * 0.29));
      let thickness = 0.2 * (1.0 - v) * (1.0 - v) + 0.014;
      let r = max(p.x * swell - inner * thickness * 0.3, 0.0);
      return vec3f(cos(angle) * r, p.y - inner * thickness, sin(angle) * r);
    }
    
    fn bodyRotate(p: vec3f) -> vec3f {
      let cx = cos(scene.motion.x);
      let sx = sin(scene.motion.x);
      let cz = cos(scene.motion.y);
      let sz = sin(scene.motion.y);
      let a = vec3f(p.x, p.y * cx - p.z * sx, p.y * sx + p.z * cx);
      return vec3f(a.x * cz - a.y * sz, a.x * sz + a.y * cz, a.z);
    }
    
    fn bodyToWorld(p: vec3f) -> vec3f { return scene.body.xyz + bodyRotate(p); }
    
    fn bellWorld(u: f32, v: f32, inner: f32) -> vec3f { return bodyToWorld(bellLocal(u, v, inner)); }
    
    /** Outward bell normal in world space, from finite differences of the parametric surface. */
    fn bellNormal(u: f32, v: f32, inner: f32) -> vec3f {
      let vv = clamp(v, 0.02, 0.985);
      let p = bellLocal(u, vv, inner);
      let pu = bellLocal(u + 0.003, vv, inner);
      let pv = bellLocal(u, vv + 0.012, inner);
      var n = normalize(cross(pu - p, pv - p));
      if (dot(n, p - vec3f(0.0, 0.2, 0.0)) < 0.0) { n = -n; }
      return bodyRotate(n);
    }
    
    /** Water velocity (units/s): drifting current, the pointer's wake and the bell's exhaled jet. */
    fn flowAt(p: vec3f) -> vec3f {
      let t = scene.flow.y;
      let q = p * 0.21;
      var flow = vec3f(
        sin(q.y * 1.7 + cos(q.z * 1.3 + t * 0.11)),
        0.45 * sin(q.z * 1.5 + cos(q.x * 1.1 - t * 0.07)),
        sin(q.x * 1.9 + cos(q.y * 1.4 + t * 0.09))) * 0.035;
    
      let origin = scene.pointerOrigin.xyz;
      let dir = scene.pointerDir.xyz;
      let rel = p - origin;
      let off = rel - dir * max(dot(rel, dir), 0.0);
      let radius = scene.pointerDir.w;
      let fall = exp(-dot(off, off) / (radius * radius)) * scene.pointerOrigin.w;
      let v = scene.pointerVel.xyz;
      let speed = length(v);
      // The hand drags water along and leaves a small eddy curling around its path.
      let swirl = cross(v / max(speed, 1e-4), off) * min(speed, 3.0) * 0.5 / max(radius, 0.2);
      flow += (v * 0.85 + swirl) * fall;
    
      // Each stroke expels water from under the bell.
      let local = p - scene.body.xyz;
      let radial = dot(local.xz, local.xz);
      let below = smoothstep(0.4, -0.2, local.y) * exp(local.y * 0.45);
      flow.y -= scene.flow.w * 0.55 * exp(-radial * 1.6) * below;
      return flow;
    }
  • sprites.wgslfragment ×3 Point sprites for every living particle: instanced quads (WebGPU points are one pixel), additive into the HDR scene so no depth sorting is needed. 96 lignes
    Fichier
    src/jelly/sprites.wgsl
    Points d’entrée
    fs_absorb_bell fragment, fs_absorb_strands fragment, fs_sprite fragment
    Ressources
    2 liaisons
    // Point sprites for every living particle: instanced quads (WebGPU points are
    // one pixel), additive into the HDR scene so no depth sorting is needed.
    
    @group(0) @binding(2) var causticMap: texture_2d<f32>;
    @group(0) @binding(3) var repeatSampler: sampler;
    
    
    struct Sprite {
      @builtin(position) position: vec4f,
      @location(0) corner: vec2f,
      @location(1) color: vec3f,
    };
    
    fn quadCorner(vertex: u32) -> vec2f {
      let corners = array<vec2f, 6>(vec2f(-1, -1), vec2f(1, -1), vec2f(-1, 1), vec2f(-1, 1), vec2f(1, -1), vec2f(1, 1));
      return corners[vertex % 6u];
    }
    
    fn bellRadiusAt(v: f32) -> f32 { return sin(BELL_ARC * v) / sin(BELL_ARC); }
    
    /** Focused sunlight at p: the surface pattern where p's light entered the water. */
    fn causticAt(p: vec3f) -> vec3f {
      let below = max(scene.depth.y - p.y, 0.0);
      let entry = p.xz + scene.sunDir.xz * (below / max(scene.sunDir.y, 0.3));
      let c = textureSampleLevel(causticMap, repeatSampler, entry / CAUSTIC_TILE, 0.0).rgb;
      return mix(vec3f(1.0), c, scene.depth.z * exp(-below * 0.025));
    }
    
    /** Light scattered by translucent tissue: brightest where we look through it edge-on. */
    fn translucent(p: vec3f, n: vec3f, albedo: vec3f, rimBoost: f32) -> vec3f {
      let toEye = normalize(scene.eye.xyz - p);
      let rim = pow(1.0 - abs(dot(n, toEye)), 2.2);
      let above = 0.6 + 0.4 * n.y;
      return albedo * scene.ambient.rgb * above * causticAt(p) * (0.045 + rimBoost * rim * 0.34);
    }
    
    /** Beer–Lambert along the view path: red is lost first, blue carries furthest. */
    fn viewFade(p: vec3f) -> vec3f {
      let d = length(scene.eye.xyz - p);
      return exp(-scene.ambient.w * d * vec3f(2.4, 1.0, 0.72));
    }
    
    /** How visible living light is: faint under the sun, everything in the dark. */
    fn bioScale() -> f32 { return 0.12 + 0.88 * scene.horizon.w; }
    
    fn hidden() -> Sprite {
      var out: Sprite;
      out.position = vec4f(2.0, 2.0, 2.0, 1.0);
      out.corner = vec2f(0.0);
      out.color = vec3f(0.0);
      return out;
    }
    
    fn sprite(p: vec3f, radius: f32, corner: vec2f, color: vec3f) -> Sprite {
      let view = scene.view * vec4f(p, 1.0);
      if (-view.z < 0.08 || max(color.r, max(color.g, color.b)) < 1e-6) { return hidden(); }
      var clip = scene.proj * view;
      var px = radius * scene.proj[1][1] * 0.5 * scene.viewport.y / clip.w;
      // Sub-pixel sprites keep their energy but not their size, so nothing sparkles.
      let minPx = 0.75 * scene.viewport.z;
      var energy = 1.0;
      if (px < minPx) {
        energy = (px * px) / (minPx * minPx);
        px = minPx;
      }
      px = min(px, 12.0 * scene.viewport.z);
      // Motes brushing the lens fade instead of filling the frame.
      energy *= smoothstep(0.1, 0.55, -view.z);
      clip.x += corner.x * px * 2.0 / scene.viewport.x * clip.w;
      clip.y += corner.y * px * 2.0 / scene.viewport.y * clip.w;
      var out: Sprite;
      out.position = clip;
      out.corner = corner;
      out.color = color * energy;
      return out;
    }
    
    /**
     * Tissue also absorbs a little light, so the animal reads against the bright surface.
     * Two fixed strengths rather than an `override`: WebKit rejects pipeline constants
     * that the vertex stage does not use.
     */
    fn absorb(input: Sprite, strength: f32) -> vec4f {
      let r2 = dot(input.corner, input.corner);
      if (r2 > 1.0) { discard; }
      return vec4f(0.0, 0.0, 0.0, (1.0 - r2) * strength);
    }
    @fragment fn fs_absorb_bell(input: Sprite) -> @location(0) vec4f { return absorb(input, 0.045); }
    @fragment fn fs_absorb_strands(input: Sprite) -> @location(0) vec4f { return absorb(input, 0.03); }
    
    @fragment fn fs_sprite(input: Sprite) -> @location(0) vec4f {
      let r2 = dot(input.corner, input.corner);
      if (r2 > 1.0) { discard; }
      let falloff = (exp(-r2 * 3.2) - 0.0408) / 0.9592;
      return vec4f(input.color * falloff, 0.0);
    }
  • bell.wgslvertex The bell: static parametric samples (see createBellPoints) shaped every frame by the stroke, then lit as translucent mesoglea with mauve nematocyst warts. 55 lignes
    Fichier
    src/jelly/bell.wgsl
    Points d’entrée
    vs_bell vertex
    Ressources
    1 liaison
    // The bell: static parametric samples (see createBellPoints) shaped every frame
    // by the stroke, then lit as translucent mesoglea with mauve nematocyst warts.
    
    @group(0) @binding(1) var<storage, read> bellPoints: array<vec4f>;
    
    /** Light waves spreading over the bell from where it was disturbed. */
    fn disturbance(u: f32, v: f32) -> f32 {
      let glow = scene.body.w;
      if (glow < 0.003) { return 0.0; }
      let origin = scene.motion.zw;
      let ring = 0.5 * (bellRadiusAt(v) + bellRadiusAt(origin.y));
      let d = length(vec2f(turnDistance(u, origin.x) * TAU * ring, (v - origin.y) * BELL_ARC));
      let t = scene.pulse.w;
      // A first bright front races out from the touch; later ones repeat more slowly.
      let first = exp(-pow((d - t * 5.5) / 0.26, 2.0)) * exp(-t * 0.7) * 1.8;
      let age = t % 1.35;
      let again = exp(-pow((d - age * 3.6) / 0.32, 2.0)) * exp(-age * 1.1) * step(1.35, t);
      let wash = 0.1 + 0.08 * sin(t * 11.0 + d * 6.0);
      return (first + again + wash) * glow;
    }
    
    @vertex fn vs_bell(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Sprite {
      let a = bellPoints[instance * 2u];
      let b = bellPoints[instance * 2u + 1u];
      let u = a.x;
      let v = a.y;
      let layer = u32(a.z + 0.5);
      let inside = layer == LAYER_SUBUMBRELLA || layer == LAYER_GONAD;
      let inner = select(select(0.0, 1.0, inside), 0.7, layer == LAYER_CANAL);
      let n = bellNormal(u, v, inner);
      let p = bellWorld(u, v, inner) + n * select(b.x, -b.x, inside);
    
      var albedo = vec3f(1.1, 0.64, 0.96);
      var radius = 0.0068 * b.y;
      var rimBoost = 1.3;
      var photocytes = 0.25;
      switch layer {
        case LAYER_SUBUMBRELLA: { albedo = vec3f(0.72, 0.6, 0.92) * 0.75; rimBoost = 0.9; photocytes = 0.15; }
        case LAYER_CANAL: { albedo = vec3f(1.0, 0.82, 0.96) * 1.15; photocytes = 0.6; }
        case LAYER_GONAD: { albedo = vec3f(1.15, 0.5, 0.72) * 1.4; radius *= 1.3; rimBoost = 0.55; photocytes = 0.3; }
        case LAYER_MARGIN: { albedo = vec3f(1.0, 0.84, 0.96) * 1.2; photocytes = 1.0; }
        case LAYER_WART: { albedo = vec3f(1.0, 0.24, 0.8) * 1.7; radius *= 1.2; rimBoost = 0.5; photocytes = 0.8; }
        default: {}
      }
      var color = translucent(p, n, albedo * b.z, rimBoost);
    
      // In the dark, each stroke stirs the photocytes a little (the bell is its own
      // turbulence); a disturbance sets them ablaze.
      let stroke = (0.04 + contractionAt(u, v) * 0.3) * smoothstep(0.55, 1.0, v) * scene.horizon.w;
      let twinkle = 0.55 + 0.45 * sin(scene.eye.w * (2.0 + 3.0 * a.w) + b.w * 40.0);
      let emitted = (disturbance(u, v) + stroke) * photocytes * twinkle;
      color += mix(BIO_BLUE, BIO_CYAN, b.w * 0.5) * emitted * 1.6 * bioScale();
    
      return sprite(p, radius, quadCorner(vertex), color * viewFade(p));
    }
  • strand-sprites.wgslvertex Points dressed along the simulated chains: beaded tentacles (nematocysts cluster in batteries) and the four oral arms as frilled, slowly twisting curtains. 89 lignes
    Fichier
    src/jelly/strand-sprites.wgsl
    Points d’entrée
    vs_strand vertex
    Ressources
    1 liaison
    // Points dressed along the simulated chains: beaded tentacles (nematocysts cluster
    // in batteries) and the four oral arms as frilled, slowly twisting curtains.
    
    struct Node { pos: vec4f, prev: vec4f };
    @group(0) @binding(1) var<storage, read> nodes: array<Node>;
    
    fn node(strand: u32, j: i32) -> vec3f {
      let k = u32(clamp(j, 0, i32(STRAND_NODES) - 1));
      return nodes[strand * STRAND_NODES + k].pos.xyz;
    }
    
    /** Catmull–Rom position and tangent at chain parameter t (0..STRAND_NODES-1). */
    fn chain(strand: u32, t: f32) -> array<vec3f, 2> {
      let j = i32(floor(t));
      let f = t - f32(j);
      let p0 = node(strand, j - 1);
      let p1 = node(strand, j);
      let p2 = node(strand, j + 1);
      let p3 = node(strand, j + 2);
      let f2 = f * f;
      let f3 = f2 * f;
      let position = 0.5 * (2.0 * p1 + (p2 - p0) * f + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * f2 + (3.0 * p1 - p0 - 3.0 * p2 + p3) * f3);
      let tangent = 0.5 * ((p2 - p0) + 2.0 * (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * f + 3.0 * (3.0 * p1 - p0 - 3.0 * p2 + p3) * f2);
      return array<vec3f, 2>(position, tangent);
    }
    
    @vertex fn vs_strand(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Sprite {
      let tentaclePoints = TENTACLES * TENTACLE_POINTS;
      let arm = instance >= tentaclePoints;
      var strand = instance / TENTACLE_POINTS;
      var k = instance % TENTACLE_POINTS;
      var count = TENTACLE_POINTS;
      if (arm) {
        let j = instance - tentaclePoints;
        strand = TENTACLES + j / ARM_POINTS;
        k = j % ARM_POINTS;
        count = ARM_POINTS;
      }
      let seed = instance * 4u;
      let r1 = rand(seed);
      let r2 = rand(seed + 1u);
      let r3 = rand(seed + 2u);
      let r4 = rand(seed + 3u);
      var s = (f32(k) + r1) / f32(count);
      let bead = !arm && r2 < 0.7;
      if (bead) { s = (floor(s * 140.0) + 0.5 + (r3 - 0.5) * 0.35) / 140.0; }
      s = clamp(s, 0.0, 0.999);
    
      let c = chain(strand, s * f32(STRAND_NODES - 1u));
      let tangent = normalize(c[1] + vec3f(0.0, -1e-5, 0.0));
      let helper = select(vec3f(0.0, 1.0, 0.0), vec3f(1.0, 0.0, 0.0), abs(tangent.y) > 0.9);
      let side = normalize(cross(tangent, helper));
      let up = cross(side, tangent);
    
      var p: vec3f;
      var n: vec3f;
      var albedo: vec3f;
      var radius: f32;
      var photocytes: f32;
      if (!arm) {
        let angle = r4 * TAU;
        n = side * cos(angle) + up * sin(angle);
        let thickness = 0.02 * (1.0 - 0.8 * s) + 0.003;
        p = c[0] + n * sqrt(rand(seed + 5u)) * thickness;
        albedo = vec3f(1.0, 0.82, 0.96) * select(0.8, 1.7, bead);
        radius = 0.0052 * (1.0 - 0.55 * s) + 0.0018;
        photocytes = select(0.15, 0.7, bead);
      } else {
        // A frilled ribbon along the arm, ruffled into folds that ripple slowly.
        let across = r2 * 2.0 - 1.0;
        let width = 0.21 * sin(PI * (0.08 + 0.92 * s)) * (1.0 - 0.3 * s) + 0.025;
        let twist = s * 4.5 + f32(strand) * 1.9;
        let ax = side * cos(twist) + up * sin(twist);
        let ay = cross(tangent, ax);
        let ruffle = sin(s * 62.0 + across * 4.5 + f32(strand) * 1.7 + scene.eye.w * 0.5) * (0.2 + 0.8 * abs(across)) * width * 0.6;
        p = c[0] + ax * across * width + ay * (ruffle + (r3 - 0.5) * 0.012);
        n = normalize(ay + ax * across * 0.4);
        let wart = r4 < 0.1;
        albedo = select(vec3f(1.15, 0.56, 0.86) * (0.6 + 0.5 * r3), vec3f(1.0, 0.24, 0.8) * 1.6, wart);
        radius = 0.0056 + 0.0022 * r3;
        photocytes = select(0.35, 0.9, wart);
      }
      var color = translucent(p, n, albedo, select(1.5, 1.0, arm));
      // Light follows the bell's flashes down the strands.
      let glow = scene.body.w * (0.35 + 0.65 * exp(-pow(s * 5.0 - (scene.pulse.w * 2.4) % 6.0, 2.0)));
      let twinkle = 0.5 + 0.5 * sin(scene.eye.w * (3.0 + 4.0 * r3) + r1 * 50.0);
      color += mix(BIO_BLUE, BIO_CYAN, r3 * 0.5) * glow * photocytes * twinkle * 1.2 * bioScale();
      return sprite(p, radius, quadCorner(vertex), color * viewFade(p));
    }
  • mote-sprites.wgslvertex Marine snow lit by the water above and by the jellyfish's own light; dinoflagellate sparks and mucus threads that only exist while they glow. 29 lignes
    Fichier
    src/jelly/mote-sprites.wgsl
    Points d’entrée
    vs_mote vertex
    Ressources
    1 liaison
    // Marine snow lit by the water above and by the jellyfish's own light;
    // dinoflagellate sparks and mucus threads that only exist while they glow.
    
    struct Mote { pos: vec4f, vel: vec4f };
    @group(0) @binding(1) var<storage, read> motes: array<Mote>;
    
    @vertex fn vs_mote(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Sprite {
      let m = motes[instance];
      let p = m.pos.xyz;
      let seed = m.pos.w;
      let excitation = m.vel.w;
      var color: vec3f;
      var radius: f32;
      if (instance < SNOW_MOTES) {
        // Flakes of a few millimeters; a rare few are large, loose aggregates.
        radius = 0.007 + 0.03 * pow(fract(seed * 7.31), 4.0);
        let fromBody = p - scene.body.xyz;
        let lamp = BIO_BLUE * scene.body.w * 0.9 / (1.0 + dot(fromBody, fromBody) * 0.8);
        let lit = scene.ambient.rgb * (0.45 + 0.55 * causticAt(p)) * 0.16 + lamp;
        color = lit * (0.35 + 0.65 * fract(seed * 3.17));
      } else {
        if (excitation < 0.01) { return hidden(); }
        let plankton = instance < SNOW_MOTES + PLANKTON_MOTES;
        radius = select(0.007 + 0.006 * fract(seed * 11.0), 0.004 + 0.005 * excitation, plankton);
        let hue = mix(BIO_BLUE, BIO_CYAN, select(0.8, fract(seed * 5.3) * 0.6, plankton));
        color = hue * select(excitation * excitation * 2.6, excitation * 4.5, plankton) * bioScale();
      }
      return sprite(p, radius, quadCorner(vertex), color * viewFade(p));
    }
  • strands.wgslcompute Tentacles and oral arms as chains simulated with dynamic follow-the-leader (Müller et al. 2012): Verlet integration in the moving water, then each node is pulled back to one segment length from its parent. One thread per strand. 101 lignes
    Fichier
    src/jelly/strands.wgsl
    Points d’entrée
    simulate compute @workgroup_size(16)
    Ressources
    1 liaison
    // Tentacles and oral arms as chains simulated with dynamic follow-the-leader
    // (Müller et al. 2012): Verlet integration in the moving water, then each node
    // is pulled back to one segment length from its parent. One thread per strand.
    
    struct Node { pos: vec4f, prev: vec4f };
    @group(0) @binding(1) var<storage, read_write> nodes: array<Node>;
    
    fn isArm(strand: u32) -> bool { return strand >= TENTACLES; }
    
    fn strandAngle(strand: u32) -> f32 {
      // Tentacles sit in the odd lappet notches, alternating with the eight rhopalia.
      if (!isArm(strand)) { return (f32(strand) * 2.0 + 1.0) / f32(LAPPETS); }
      return f32(strand - TENTACLES) / f32(ORAL_ARMS);
    }
    
    fn strandRoot(strand: u32) -> vec3f {
      let u = strandAngle(strand);
      if (!isArm(strand)) { return bellWorld(u, 0.975, 0.55); }
      // The four arms hang from the manubrium under the center of the bell.
      let a = u * TAU;
      let hub = bellLocal(u, 0.0, 1.0) + vec3f(cos(a) * 0.07, -0.16, sin(a) * 0.07);
      return bodyToWorld(hub);
    }
    
    fn strandRest(strand: u32) -> vec3f {
      let a = strandAngle(strand) * TAU;
      let spread = select(0.32, 0.12, isArm(strand));
      return bodyRotate(normalize(vec3f(cos(a) * spread, -1.0, sin(a) * spread)));
    }
    
    fn strandSegment(strand: u32) -> f32 {
      let jitter = rand(strand * 7919u + 17u);
      if (isArm(strand)) { return 2.9 * (0.88 + 0.24 * jitter) / f32(STRAND_NODES - 1u); }
      // Startled, Pelagia draws its tentacles in.
      let retract = 1.0 - 0.42 * scene.body.w;
      return 7.2 * (0.75 + 0.5 * jitter) * retract / f32(STRAND_NODES - 1u);
    }
    
    @compute @workgroup_size(16)
    fn simulate(@builtin(global_invocation_id) id: vec3u) {
      let strand = id.x;
      if (strand >= STRANDS) { return; }
      let base = strand * STRAND_NODES;
      let root = strandRoot(strand);
      let rest = strandRest(strand);
      let seg = strandSegment(strand);
      let dt = scene.pointerVel.w;
      let arm = isArm(strand);
      var p: array<vec3f, STRAND_NODES>;
      var q: array<vec3f, STRAND_NODES>;
      let fresh = nodes[base].pos.w < 0.5;
      for (var j = 0u; j < STRAND_NODES; j++) {
        if (fresh) {
          let x = root + rest * seg * f32(j);
          p[j] = x;
          q[j] = x;
        } else {
          p[j] = nodes[base + j].pos.xyz;
          q[j] = nodes[base + j].prev.xyz;
        }
      }
      p[0] = root;
      q[0] = root;
    
      // Water drag is strong at this scale: strands relax toward the local current,
      // while a slight negative buoyancy lets them hang when the water is still.
      let drag = 1.0 - exp(-dt * select(6.5, 5.0, arm));
      let sink = vec3f(0.0, select(-0.3, -0.1, arm), 0.0);
      for (var j = 1u; j < STRAND_NODES; j++) {
        let x = p[j];
        var velocity = (x - q[j]) * 0.965;
        // Weight grows toward the tip, which pulls slack out instead of letting it coil.
        let weight = sink * (0.6 + 0.8 * f32(j) / f32(STRAND_NODES));
        velocity = mix(velocity, (flowAt(x) + weight) * dt, drag);
        q[j] = x;
        p[j] = x + velocity;
      }
    
      for (var j = 1u; j < STRAND_NODES; j++) {
        // Bending stiffness is highest near the root and fades toward the tip.
        var straight: vec3f;
        var stiffness: f32;
        if (j == 1u) {
          straight = p[0] + rest * seg;
          stiffness = 0.7;
        } else {
          straight = p[j - 1u] + normalize(p[j - 1u] - p[j - 2u] + vec3f(0.0, -1e-5, 0.0)) * seg;
          stiffness = select(0.1 * exp(-f32(j) / 10.0) + 0.05, 0.3 * exp(-f32(j) / 10.0) + 0.05, arm);
        }
        var goal = mix(p[j], straight, stiffness);
        let d = goal - p[j - 1u];
        goal = p[j - 1u] + d / max(length(d), 1e-5) * seg;
        // Keeping the correction out of the velocity stops the chain from gaining energy.
        q[j] += goal - p[j];
        p[j] = goal;
      }
    
      for (var j = 0u; j < STRAND_NODES; j++) {
        nodes[base + j] = Node(vec4f(p[j], 1.0), vec4f(q[j], 1.0));
      }
    }
  • motes.wgslcompute Suspended particles: marine snow (sinking at ~0.5 mm/s, nearly still beside the jellyfish), dinoflagellates that flash for ~0.2 s when the water is sheared, and the luminous mucus Pelagia sheds when disturbed. Motes wrap around the camera target, so the water appears endless and rises past us during the descent. 59 lignes
    Fichier
    src/jelly/motes.wgsl
    Points d’entrée
    drift compute @workgroup_size(64)
    Ressources
    1 liaison
    // Suspended particles: marine snow (sinking at ~0.5 mm/s, nearly still beside the
    // jellyfish), dinoflagellates that flash for ~0.2 s when the water is sheared, and
    // the luminous mucus Pelagia sheds when disturbed. Motes wrap around the camera
    // target, so the water appears endless and rises past us during the descent.
    
    struct Mote { pos: vec4f, vel: vec4f }; // pos.w: seed, vel.w: excitation (0..1)
    @group(0) @binding(1) var<storage, read_write> motes: array<Mote>;
    
    @compute @workgroup_size(64)
    fn drift(@builtin(global_invocation_id) id: vec3u) {
      let i = id.x;
      if (i >= MOTES) { return; }
      var m = motes[i];
      let dt = scene.pointerVel.w;
      let seed = m.pos.w;
      var p = m.pos.xyz;
      var excitation = m.vel.w;
    
      if (i >= SNOW_MOTES + PLANKTON_MOTES) {
        if (excitation < 0.02) {
          // Parked mucus respawns on the bell while it is disturbed.
          let roll = hash3(vec3f(seed * 91.7, scene.eye.w * 7.13, f32(i) * 0.37));
          if (roll < scene.body.w * dt * 0.8) {
            let u = rand(i * 3u + u32(scene.eye.w * 240.0));
            let v = 0.2 + 0.8 * rand(i * 5u + 11u + u32(scene.eye.w * 240.0));
            let n = bellNormal(u, v, 0.0);
            p = bellWorld(u, v, 0.0) + n * 0.03;
            motes[i] = Mote(vec4f(p, seed), vec4f(n * 0.1 + flowAt(p) * 0.5, 1.0));
          } else {
            motes[i] = Mote(m.pos, vec4f(0.0));
          }
          return;
        }
        // Threads of mucus drift away and fade over a few seconds.
        let velocity = mix(m.vel.xyz, flowAt(p), 1.0 - exp(-dt * 1.2));
        p += velocity * dt;
        motes[i] = Mote(vec4f(p, seed), vec4f(velocity, excitation * exp(-dt / 2.6)));
        return;
      }
    
      let flow = flowAt(p);
      let velocity = mix(m.vel.xyz, flow, 1.0 - exp(-dt * 2.5));
      let sinking = select(0.004, 0.012 + 0.012 * fract(seed * 13.0), i < SNOW_MOTES);
      p += (velocity + vec3f(0.0, scene.flow.x - sinking, 0.0)) * dt;
      let center = scene.frame.xyz;
      let span = scene.frame.w * 2.0;
      p = center + (fract((p - center) / span + 0.5) - 0.5) * span;
    
      if (i >= SNOW_MOTES) {
        // Shear from the hand or the bell's jet makes dinoflagellates flash (~0.15–0.6 s); a flash
        // must mostly fade before the cell can fire again.
        excitation *= exp(-dt / 0.35);
        let stir = smoothstep(0.14, 0.5, length(flow));
        let near = length(p - scene.body.xyz);
        let alarm = scene.body.w * smoothstep(2.6, 0.8, near) * step(0.985, hash3(vec3f(seed * 37.0, scene.eye.w * 3.1, 1.0)));
        if (excitation < 0.06) { excitation = max(excitation, max(stir, alarm)); }
      }
      motes[i] = Mote(vec4f(p, seed), vec4f(velocity, excitation));
    }
  • ocean-fft.wgslvertexfragment ×4 A real inverse-FFT ocean: a Phillips spectrum h0(k) seeded once, evolved with deep-water dispersion every frame, then brought back to space by radix-2 Stockham passes. Adapted from the vgpu `fft-ocean` example (MIT, vercel-labs/vgpu). Each entry point is compiled as its own fullscreen effect over the 256² grid. 118 lignes
    Fichier
    src/jelly/ocean-fft.wgsl
    Points d’entrée
    vs_fullscreen vertex, initial fragment, evolve fragment, ifft fragment, surface fragment
    Ressources
    2 liaisons
    // A real inverse-FFT ocean: a Phillips spectrum h0(k) seeded once, evolved with
    // deep-water dispersion every frame, then brought back to space by radix-2
    // Stockham passes. Adapted from the vgpu `fft-ocean` example (MIT, vercel-labs/vgpu).
    // Each entry point is compiled as its own fullscreen effect over the 256² grid.
    
    const OCEAN_N: u32 = 256u;
    const G: f32 = 9.81;
    const PI: f32 = 3.14159265;
    
    struct Ocean {
      size: f32,          // spectrum domain (m)
      windSpeed: f32,     // m/s
      windAngle: f32,     // radians
      amplitude: f32,     // Phillips A
      time: f32,          // seconds
      subtransform: f32,  // IFFT stage span
      horizontal: f32,    // 1: rows, 0: columns
      heightScale: f32,   // world units per spectrum height unit
      gradientScale: f32, // the same, divided by world units per spectrum meter
      smallWaves: f32,    // damping length (m): caustics come from the swell, not the ripples
    };
    @group(0) @binding(0) var<uniform> ocean: Ocean;
    @group(0) @binding(1) var source: texture_2d<f32>;
    
    struct Fullscreen { @builtin(position) position: vec4f, @location(0) uv: vec2f };
    @vertex fn vs_fullscreen(@builtin(vertex_index) vertex: u32) -> Fullscreen {
      let corner = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0))[vertex];
      return Fullscreen(vec4f(corner, 0.0, 1.0), corner * vec2f(0.5, -0.5) + 0.5);
    }
    
    fn cmul(a: vec2f, b: vec2f) -> vec2f { return vec2f(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x); }
    
    fn wrapLoad(coord: vec2i) -> vec4f {
      let n = i32(OCEAN_N);
      return textureLoad(source, vec2u((coord % vec2i(n) + vec2i(n)) % vec2i(n)), 0);
    }
    
    fn waveVector(position: vec2f) -> vec2f {
      let coord = position - vec2f(0.5);
      let half = f32(OCEAN_N) * 0.5;
      let n = select(coord.x - f32(OCEAN_N), coord.x, coord.x < half);
      let m = select(coord.y - f32(OCEAN_N), coord.y, coord.y < half);
      return (2.0 * PI / ocean.size) * vec2f(n, m);
    }
    
    // Random-access mulberry32 so each texel reproduces the same Gaussian pairs.
    fn mulberryAt(call: u32) -> f32 {
      var t = 0x6f636561u + 0x6d2b79f5u * (call + 1u);
      t = (t ^ (t >> 15u)) * (t | 1u);
      t = t ^ (t + ((t ^ (t >> 7u)) * (t | 61u)));
      return f32(t ^ (t >> 14u)) / 4294967296.0;
    }
    fn gaussianPair(call: u32) -> vec2f {
      let u1 = max(mulberryAt(call), 1.17549435e-38);
      let u2 = mulberryAt(call + 1u);
      return sqrt(-2.0 * log(u1)) * vec2f(cos(6.28318531 * u2), sin(6.28318531 * u2));
    }
    
    fn phillips(k: vec2f) -> f32 {
      let kk = dot(k, k);
      if (kk < 1e-8) { return 0.0; }
      let w = vec2f(cos(ocean.windAngle), sin(ocean.windAngle));
      let L = ocean.windSpeed * ocean.windSpeed / G;
      let kdotw = dot(normalize(k), w);
      var ph = ocean.amplitude * exp(-1.0 / (kk * L * L)) / (kk * kk) * kdotw * kdotw;
      ph *= exp(-kk * ocean.smallWaves * ocean.smallWaves);
      if (kdotw < 0.0) { ph *= 0.07; }
      return ph;
    }
    
    /** h0(k) and conj(h0(-k)), seeded once. */
    @fragment fn initial(@builtin(position) position: vec4f) -> @location(0) vec4f {
      let coord = vec2u(position.xy - vec2f(0.5));
      let base = (coord.y * OCEAN_N + coord.x) * 4u;
      let rnd = vec4f(gaussianPair(base), gaussianPair(base + 2u));
      let k = waveVector(position.xy);
      let h0k = 0.70710678 * rnd.xy * sqrt(phillips(k));
      let h0negk = 0.70710678 * rnd.zw * sqrt(phillips(-k));
      return vec4f(h0k, h0negk.x, -h0negk.y);
    }
    
    /** h(k, t) with the slopes i·k·h packed as one complex pair for the inverse transform. */
    @fragment fn evolve(@builtin(position) position: vec4f) -> @location(0) vec4f {
      let k = waveVector(position.xy);
      let h0 = textureLoad(source, vec2u(position.xy - vec2f(0.5)), 0);
      let w = sqrt(G * length(k)) * ocean.time;
      let h = cmul(h0.rg, vec2f(cos(w), sin(w))) + cmul(h0.ba, vec2f(cos(w), -sin(w)));
      // A = h + i·(i kx h) → real part: height, imaginary part: ∂h/∂x (after the IFFT).
      // B = i kz h → real part: ∂h/∂z.
      let ih = vec2f(-h.y, h.x);
      let dx = ih * k.x;
      let dz = ih * k.y;
      return vec4f(h.x - dx.y, h.y + dx.x, dz);
    }
    
    /** One radix-2 Stockham stage, along rows or columns. */
    @fragment fn ifft(@builtin(position) position: vec4f) -> @location(0) vec4f {
      let horizontal = ocean.horizontal > 0.5;
      let index = select(position.y - 0.5, position.x - 0.5, horizontal);
      let span = ocean.subtransform;
      let even = floor(index / span) * (span * 0.5) + (index % (span * 0.5));
      let half = f32(OCEAN_N) * 0.5;
      let x = i32(position.x - 0.5);
      let y = i32(position.y - 0.5);
      let evenCoord = select(vec2i(x, i32(even)), vec2i(i32(even), y), horizontal);
      let oddCoord = select(vec2i(x, i32(even + half)), vec2i(i32(even + half), y), horizontal);
      let a = wrapLoad(evenCoord);
      let b = wrapLoad(oddCoord);
      let angle = 2.0 * PI * (index / span);
      let twiddle = vec2f(cos(angle), sin(angle));
      return vec4f(a.xy + cmul(twiddle, b.xy), a.zw + cmul(twiddle, b.zw));
    }
    
    /** Height and analytic slopes, in world units: (∂h/∂x, h, ∂h/∂z, 0). */
    @fragment fn surface(@builtin(position) position: vec4f) -> @location(0) vec4f {
      let v = textureLoad(source, vec2u(position.xy - vec2f(0.5)), 0);
      return vec4f(v.y * ocean.gradientScale, v.x * ocean.heightScale, v.z * ocean.gradientScale, 1.0);
    }
  • caustics.wgslfragment Jacobian caustics from the FFT surface (after greggman/webgpu-aquarium, MIT). Refraction shifts light by about alpha·∇h; where that map folds, light focuses: intensity = 1/|det(I + alpha·Hess h)|. Slightly different alphas per channel give the faint chromatic fringes of real caustics. 34 lignes
    Fichier
    src/jelly/caustics.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    2 liaisons
    // Jacobian caustics from the FFT surface (after greggman/webgpu-aquarium, MIT).
    // Refraction shifts light by about alpha·∇h; where that map folds, light focuses:
    // intensity = 1/|det(I + alpha·Hess h)|. Slightly different alphas per channel
    // give the faint chromatic fringes of real caustics.
    
    struct Caustic { alpha: f32, texel: f32 };
    @group(0) @binding(0) var<uniform> caustic: Caustic;
    @group(0) @binding(1) var surface: texture_2d<f32>;
    
    fn tap(coord: vec2i) -> vec4f {
      let n = vec2i(textureDimensions(surface));
      return textureLoad(surface, vec2u((coord % n + n) % n), 0);
    }
    
    fn intensity(hxx: f32, hxz: f32, hzz: f32, alpha: f32) -> f32 {
      let det = (1.0 + alpha * hxx) * (1.0 + alpha * hzz) - alpha * alpha * hxz * hxz;
      return 1.0 / max(abs(det), 0.08);
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
      let c = vec2i(position.xy);
      let r = tap(c + vec2i(1, 0));
      let l = tap(c - vec2i(1, 0));
      let t = tap(c + vec2i(0, 1));
      let b = tap(c - vec2i(0, 1));
      let inv = 0.5 / caustic.texel;
      let hxx = (r.x - l.x) * inv;
      let hzz = (t.z - b.z) * inv;
      let hxz = ((t.x - b.x) + (r.z - l.z)) * 0.5 * inv;
      let a = caustic.alpha;
      let rgb = vec3f(intensity(hxx, hxz, hzz, a * 0.975), intensity(hxx, hxz, hzz, a), intensity(hxx, hxz, hzz, a * 1.025));
      // Compress the long tail so focal lines stay bright but never blow out; mean ≈ 1.
      return vec4f(pow(rgb, vec3f(1.25)) * 0.55, 1.0);
    }
  • caustic-blur.wgslfragment A soft, low-resolution copy of the caustics for the light shafts: shafts are the pattern seen edge-on, and a fine pattern would alias into a comb. 16 lignes
    Fichier
    src/jelly/caustic-blur.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    1 liaison
    // A soft, low-resolution copy of the caustics for the light shafts: shafts are
    // the pattern seen edge-on, and a fine pattern would alias into a comb.
    
    @group(0) @binding(0) var sharp: texture_2d<f32>;
    
    @fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
      let size = vec2i(textureDimensions(sharp));
      let origin = vec2i(position.xy) * 8;
      var sum = 0.0;
      for (var y = 0; y < 8; y++) {
        for (var x = 0; x < 8; x++) {
          sum += textureLoad(sharp, vec2u((origin + vec2i(x, y)) % size), 0).g;
        }
      }
      return vec4f(sum / 64.0, 0.0, 0.0, 1.0);
    }
  • water.wgslfragment Open water behind every particle: in-scattered light that brightens toward the surface, the rippled surface seen from below (Snell's window inside ~48.6°, total internal reflection outside), and the half-resolution light shafts. Snell's window and the scattering terms follow greggman/webgpu-aquarium (MIT). 72 lignes
    Fichier
    src/jelly/water.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    4 liaisons
    // Open water behind every particle: in-scattered light that brightens toward the
    // surface, the rippled surface seen from below (Snell's window inside ~48.6°,
    // total internal reflection outside), and the half-resolution light shafts.
    // Snell's window and the scattering terms follow greggman/webgpu-aquarium (MIT).
    
    @group(0) @binding(1) var oceanSurface: texture_2d<f32>;
    @group(0) @binding(2) var shafts: texture_2d<f32>;
    @group(0) @binding(3) var repeatSampler: sampler;
    @group(0) @binding(4) var clampSampler: sampler;
    
    fn rayDirection(uv: vec2f) -> vec3f {
      let far = scene.invViewProj * vec4f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 1.0, 1.0);
      return normalize(far.xyz / far.w - scene.eye.xyz);
    }
    
    /** Water seen along dir with nothing in the way. */
    fn openWater(dir: vec3f) -> vec3f {
      var color = mix(scene.abyss.rgb, scene.horizon.rgb, smoothstep(-0.5, 0.45, dir.y));
      color = mix(color, scene.horizon.rgb * 1.7 + scene.ambient.rgb * 0.04, smoothstep(0.2, 1.0, dir.y));
      // Forward scattering gathers into a glow around the sun's direction.
      let mu = max(dot(dir, scene.sunDir.xyz), 0.0);
      color += scene.ambient.rgb * scene.sunDir.w * (pow(mu, 5.0) * 0.07 + pow(mu, 32.0) * 0.16);
      return color;
    }
    
    fn sunInAir() -> vec3f {
      let horizontal = scene.sunDir.xz * 1.333;
      return vec3f(horizontal.x, sqrt(max(1.0 - dot(horizontal, horizontal), 0.0)), horizontal.y);
    }
    
    fn sky(d: vec3f) -> vec3f {
      let mu = max(dot(d, sunInAir()), 0.0);
      let horizon = vec3f(0.95, 1.0, 1.05) * 0.95;
      let zenith = vec3f(0.4, 0.66, 1.15) * 0.85;
      var color = mix(horizon, zenith, pow(clamp(d.y, 0.0, 1.0), 0.6));
      color += vec3f(1.0, 0.95, 0.84) * (pow(mu, 1400.0) * 36.0 + pow(mu, 55.0) * 1.8 + pow(mu, 6.0) * 0.3);
      return color;
    }
    
    fn surfaceFromBelow(dir: vec3f, distance: f32) -> vec3f {
      let hit = scene.eye.xz + dir.xz * distance;
      let rot = mat2x2f(0.8, 0.6, -0.6, 0.8);
      let swell = textureSampleLevel(oceanSurface, repeatSampler, hit / CAUSTIC_TILE, 0.0);
      let chop = textureSampleLevel(oceanSurface, repeatSampler, rot * hit / (CAUSTIC_TILE * 0.37) + 0.31, 0.0);
      // Far away the ripples average out, which keeps the edge of the window soft.
      let fade = 1.0 / (1.0 + distance * 0.05);
      let slope = (swell.xz * 1.0 + (transpose(rot) * chop.xz) * 0.45) * fade;
      let n = normalize(vec3f(slope.x, -1.0, slope.y));
      let cosI = clamp(dot(-dir, n), 0.0, 1.0);
      let mirrored = openWater(reflect(dir, n)) * 0.85;
      let sinT2 = 1.333 * 1.333 * (1.0 - cosI * cosI);
      let window = smoothstep(1.0, 0.82, sinT2);
      let cosT = sqrt(max(1.0 - sinT2, 1e-4));
      let rs = (1.333 * cosI - cosT) / (1.333 * cosI + cosT);
      let rp = (cosI - 1.333 * cosT) / (cosI + 1.333 * cosT);
      let fresnel = clamp(0.5 * (rs * rs + rp * rp), 0.0, 1.0);
      let through = mix(sky(normalize(refract(dir, n, 1.333) + vec3f(0.0, 1e-3, 0.0))), mirrored, fresnel);
      return mix(mirrored, through, window);
    }
    
    @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
      let dir = rayDirection(uv);
      var color = openWater(dir);
      let visible = smoothstep(0.0, 0.25, scene.depth.w);
      if (dir.y > 0.02 && visible > 0.0) {
        let distance = (scene.depth.y - scene.eye.y) / dir.y;
        let t = exp(-scene.ambient.w * distance * vec3f(1.8, 1.0, 0.75)) * visible;
        color = mix(color, surfaceFromBelow(dir, distance), t);
      }
      color += textureSampleLevel(shafts, clampSampler, uv, 0.0).rgb;
      return vec4f(color, 1.0);
    }
  • shafts.wgslfragment Sun shafts: a short raymarch at half resolution. Each sample is lit by the blurred caustic pattern where its sunlight entered the water, which is what turns uniform haze into beams (after greggman/webgpu-aquarium, MIT). 56 lignes
    Fichier
    src/jelly/shafts.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    2 liaisons
    // Sun shafts: a short raymarch at half resolution. Each sample is lit by the
    // blurred caustic pattern where its sunlight entered the water, which is what
    // turns uniform haze into beams (after greggman/webgpu-aquarium, MIT).
    
    @group(0) @binding(1) var causticBlur: texture_2d<f32>;
    @group(0) @binding(2) var repeatSampler: sampler;
    
    fn rayDirection(uv: vec2f) -> vec3f {
      let far = scene.invViewProj * vec4f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 1.0, 1.0);
      return normalize(far.xyz / far.w - scene.eye.xyz);
    }
    
    /** Interleaved gradient noise (Jimenez 2014) to dither the march. */
    fn ign(pixel: vec2f) -> f32 {
      return fract(52.9829189 * fract(dot(pixel, vec2f(0.06711056, 0.00583715))));
    }
    
    fn beam(p: vec3f) -> f32 {
      let below = max(scene.depth.y - p.y, 0.0);
      let entry = p.xz + scene.sunDir.xz * (below / max(scene.sunDir.y, 0.3));
      let rot = mat2x2f(0.8, 0.6, -0.6, 0.8);
      let broad = textureSampleLevel(causticBlur, repeatSampler, entry / (CAUSTIC_TILE * 1.7), 0.0).r;
      let cluster = textureSampleLevel(causticBlur, repeatSampler, rot * entry / (CAUSTIC_TILE * 5.1) + 0.17, 0.0).r;
      let peak = pow(broad * mix(0.35, 1.6, smoothstep(0.75, 1.35, cluster)), 5.0);
      let taper = 0.35 + 0.65 * exp(-below * 0.02);
      return peak / (1.0 + peak * 0.04) * taper;
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      if (scene.depth.w < 0.002) { return vec4f(0.0, 0.0, 0.0, 1.0); }
      let dir = rayDirection(uv);
      var distance = 42.0;
      if (dir.y > 0.0) { distance = min(distance, (scene.depth.y - scene.eye.y) / dir.y); }
      let jitter = ign(position.xy);
      let steps = 14u;
      var accum = 0.0;
      var previous = 0.0;
      for (var i = 0u; i < steps; i++) {
        let f = (f32(i) + jitter) / f32(steps);
        let t = f * f * distance;
        let dt = max(t - previous, 0.0) + distance / f32(steps * steps);
        previous = t;
        let p = scene.eye.xyz + dir * t;
        // Thin the first units, so a shaft around the camera never veils the view.
        let near = smoothstep(0.4, 4.0, t) * smoothstep(42.0, 14.0, t);
        accum += beam(p) * exp(-scene.ambient.w * 1.6 * t) * near * dt;
      }
      let mu = max(dot(dir, scene.sunDir.xyz), 0.0);
      let phase = 0.07 + 0.55 * pow(mu, 4.0);
      let lookDown = mix(0.2, 1.0, smoothstep(-0.85, -0.1, dir.y));
      // Beams take the water's hue: light scattered on its way down.
      let hue = scene.horizon.rgb / max(dot(scene.horizon.rgb, vec3f(0.2126, 0.7152, 0.0722)), 1e-4);
      let tint = mix(vec3f(1.0), hue, 0.5);
      let raw = scene.ambient.rgb * tint * accum * phase * lookDown * scene.depth.w * 0.032;
      return vec4f(raw / (1.0 + dot(raw, vec3f(0.2126, 0.7152, 0.0722)) * 1.2), 1.0);
    }
  • bloom.wgslvertexfragment ×2 Bloom as a mip chain: 13-tap downsamples (Karis-weighted, so single bright sparks cannot flicker) and tent upsamples added back level by level, as in Call of Duty: Advanced Warfare. Structure after greggman/webgpu-aquarium (MIT). 56 lignes
    Fichier
    src/jelly/bloom.wgsl
    Points d’entrée
    vs_fullscreen vertex, down fragment, up fragment
    Ressources
    2 liaisons
    // Bloom as a mip chain: 13-tap downsamples (Karis-weighted, so single bright
    // sparks cannot flicker) and tent upsamples added back level by level, as in
    // Call of Duty: Advanced Warfare. Structure after greggman/webgpu-aquarium (MIT).
    
    @group(0) @binding(0) var source: texture_2d<f32>;
    @group(0) @binding(1) var linearClamp: sampler;
    
    struct Fullscreen { @builtin(position) position: vec4f, @location(0) uv: vec2f };
    @vertex fn vs_fullscreen(@builtin(vertex_index) vertex: u32) -> Fullscreen {
      let corner = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0))[vertex];
      return Fullscreen(vec4f(corner, 0.0, 1.0), corner * vec2f(0.5, -0.5) + 0.5);
    }
    
    fn tap(uv: vec2f) -> vec3f { return textureSampleLevel(source, linearClamp, uv, 0.0).rgb; }
    fn karis(c: vec3f) -> f32 { return 1.0 / (1.0 + dot(c, vec3f(0.2126, 0.7152, 0.0722)) * 0.25); }
    
    @fragment fn down(input: Fullscreen) -> @location(0) vec4f {
      let t = 1.0 / vec2f(textureDimensions(source));
      let uv = input.uv;
      let a = tap(uv + t * vec2f(-2.0, -2.0));
      let b = tap(uv + t * vec2f(0.0, -2.0));
      let c = tap(uv + t * vec2f(2.0, -2.0));
      let d = tap(uv + t * vec2f(-2.0, 0.0));
      let e = tap(uv);
      let f = tap(uv + t * vec2f(2.0, 0.0));
      let g = tap(uv + t * vec2f(-2.0, 2.0));
      let h = tap(uv + t * vec2f(0.0, 2.0));
      let k = tap(uv + t * vec2f(2.0, 2.0));
      let j = tap(uv + t * vec2f(-1.0, -1.0));
      let l = tap(uv + t * vec2f(1.0, -1.0));
      let m = tap(uv + t * vec2f(-1.0, 1.0));
      let n = tap(uv + t * vec2f(1.0, 1.0));
      let g0 = (j + l + m + n) * 0.25;
      let g1 = (a + b + d + e) * 0.25;
      let g2 = (b + c + e + f) * 0.25;
      let g3 = (d + e + g + h) * 0.25;
      let g4 = (e + f + h + k) * 0.25;
      let w0 = karis(g0) * 0.5;
      let w1 = karis(g1) * 0.125;
      let w2 = karis(g2) * 0.125;
      let w3 = karis(g3) * 0.125;
      let w4 = karis(g4) * 0.125;
      let color = (g0 * w0 + g1 * w1 + g2 * w2 + g3 * w3 + g4 * w4) / (w0 + w1 + w2 + w3 + w4);
      // One non-finite pixel would come back from the chain as a block: refuse it here.
      let safe = select(vec3f(0.0), color, color == color);
      return vec4f(min(safe, vec3f(64.0)), 1.0);
    }
    
    @fragment fn up(input: Fullscreen) -> @location(0) vec4f {
      let t = 1.0 / vec2f(textureDimensions(source));
      let uv = input.uv;
      var c = tap(uv) * 4.0;
      c += (tap(uv + vec2f(-t.x, 0.0)) + tap(uv + vec2f(t.x, 0.0)) + tap(uv + vec2f(0.0, -t.y)) + tap(uv + vec2f(0.0, t.y))) * 2.0;
      c += tap(uv + vec2f(-t.x, -t.y)) + tap(uv + vec2f(t.x, -t.y)) + tap(uv + vec2f(-t.x, t.y)) + tap(uv + vec2f(t.x, t.y));
      return vec4f(c / 16.0, 1.0);
    }
  • present.wgslfragment Final image: exposure, bloom, ACES filmic curve, a soft vignette, then sRGB with a little noise so the long dark blue gradients never band on 8-bit screens. 29 lignes
    Fichier
    src/jelly/present.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    3 liaisons
    // Final image: exposure, bloom, ACES filmic curve, a soft vignette, then sRGB
    // with a little noise so the long dark blue gradients never band on 8-bit screens.
    
    @group(0) @binding(1) var hdr: texture_2d<f32>;
    @group(0) @binding(2) var bloom: texture_2d<f32>;
    @group(0) @binding(3) var clampSampler: sampler;
    
    fn aces(x: vec3f) -> vec3f {
      return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3f(0.0), vec3f(1.0));
    }
    
    fn toSrgb(c: vec3f) -> vec3f {
      return select(1.055 * pow(c, vec3f(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3f(0.0031308));
    }
    
    fn noise(pixel: vec2f, frame: f32) -> f32 {
      return fract(52.9829189 * fract(dot(pixel + frame * vec2f(5.588238, 5.588238), vec2f(0.06711056, 0.00583715))));
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let scene_ = textureSampleLevel(hdr, clampSampler, uv, 0.0).rgb;
      let glow = textureSampleLevel(bloom, clampSampler, uv, 0.0).rgb;
      var color = (scene_ + glow * scene.abyss.w) * scene.flow.z;
      let centered = uv - 0.5;
      color *= 1.0 - 0.32 * smoothstep(0.25, 0.85, dot(centered, centered) * 2.0);
      color = toSrgb(aces(color));
      let dither = (noise(position.xy, fract(scene.eye.w * 7.0) * 64.0) + noise(position.yx + 17.0, 3.0) - 1.0) / 255.0;
      return vec4f(color + dither, 1.0);
    }

Ce que dit le vent

WebGPU · WGSL · vgpu

9 shaders · 1 464 lignes · 1 partagé

Ouvrir la scène
  • common.wgslmodule Shared by the /carillon shaders. The TypeScript side prepends the constants generated from the chime design (tube count, bore, string count). 65 lignes
    Fichier
    src/carillon/common.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    1 liaison
    // Shared by the /carillon shaders. The TypeScript side prepends the constants
    // generated from the chime design (tube count, bore, string count).
    
    struct Scene {
      view: mat4x4f,
      proj: mat4x4f,
      invViewProj: mat4x4f,
      eye: vec4f,       // camera position, w: scene clock (s)
      sunDir: vec4f,    // toward the sun, w: how much of the disc clears the hills
      sunColor: vec4f,  // sun radiance (linear HDR), w: how much the horizon glow gathers toward the sun
      moonDir: vec4f,   // toward the moon, w: moonlight (0..1)
      zenith: vec4f,    // sky overhead, w: exposure
      horizon: vec4f,   // sky at the horizon, w: night (0..1)
      ground: vec4f,    // the far meadow, w: haze
      canopy: vec4f,    // the apple tree's shade, w: gust (0..1)
      wind: vec4f,      // wind at the chime (m/s), w: wind clock (s)
      viewport: vec4f,  // width, height (px), pixel ratio, samples per pixel
      look: vec4f,      // x: stars, y: fireflies, z: bloom, w: motion allowed (0/1)
    };
    @group(0) @binding(0) var<uniform> scene: Scene;
    
    const PI: f32 = 3.14159265;
    
    fn hash11(p: f32) -> f32 { return fract(sin(p * 127.1) * 43758.5453); }
    fn hash21(p: vec2f) -> f32 {
      var q = fract(p * vec2f(0.1031, 0.1030));
      q += dot(q, q.yx + 33.33);
      return fract((q.x + q.y) * q.x);
    }
    fn hash22(p: vec2f) -> vec2f {
      var q = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));
      q += dot(q, q.yzx + 33.33);
      return fract((q.xx + q.yz) * q.zy);
    }
    fn hash31(p: vec3f) -> f32 {
      var q = fract(p * 0.1031);
      q += dot(q, q.zyx + 31.32);
      return fract((q.x + q.y) * q.z);
    }
    
    fn noise2(p: vec2f) -> f32 {
      let i = floor(p);
      let f = fract(p);
      let u = f * f * (3.0 - 2.0 * f);
      return mix(mix(hash21(i), hash21(i + vec2f(1.0, 0.0)), u.x), mix(hash21(i + vec2f(0.0, 1.0)), hash21(i + vec2f(1.0, 1.0)), u.x), u.y);
    }
    fn noise3(p: vec3f) -> f32 {
      let i = floor(p);
      let f = fract(p);
      let u = f * f * (3.0 - 2.0 * f);
      let a = mix(mix(hash31(i), hash31(i + vec3f(1.0, 0.0, 0.0)), u.x), mix(hash31(i + vec3f(0.0, 1.0, 0.0)), hash31(i + vec3f(1.0, 1.0, 0.0)), u.x), u.y);
      let b = mix(mix(hash31(i + vec3f(0.0, 0.0, 1.0)), hash31(i + vec3f(1.0, 0.0, 1.0)), u.x), mix(hash31(i + vec3f(0.0, 1.0, 1.0)), hash31(i + vec3f(1.0, 1.0, 1.0)), u.x), u.y);
      return mix(a, b, u.z);
    }
    fn fbm2(p: vec2f) -> f32 {
      var sum = 0.0;
      var amplitude = 0.5;
      var q = p;
      for (var i = 0; i < 4; i++) {
        sum += amplitude * noise2(q);
        q = q * 2.03 + vec2f(17.1, 3.7);
        amplitude *= 0.5;
      }
      return sum;
    }
  • shared.wgslmodule Shared by the /carillon scene and grass passes: camera rays, the sun and moon, sky light. 32 lignes
    Fichier
    src/carillon/shared.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    0 liaison
    // Shared by the /carillon scene and grass passes: camera rays, the sun and moon, sky light.
    
    fn rayDirection(uv: vec2f) -> vec3f {
      let far = scene.invViewProj * vec4f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 1.0, 1.0);
      return normalize(far.xyz / far.w - scene.eye.xyz);
    }
    
    /** Closest approach of a ray and a segment: (distance, t along the ray, s along the segment). */
    fn raySegment(ro: vec3f, rd: vec3f, a: vec3f, b: vec3f) -> vec3f {
      let ba = b - a;
      let oa = ro - a;
      let baba = dot(ba, ba);
      let bard = dot(ba, rd);
      let denom = baba - bard * bard;
      var s = 0.0;
      if (denom > 1e-8) { s = clamp((dot(ba, oa) - bard * dot(rd, oa)) / denom, 0.0, 1.0); }
      let t = max(bard * s - dot(rd, oa), 0.0);
      return vec3f(length(oa + rd * t - ba * s), t, s);
    }
    
    struct Lights { sun: vec3f, sunDir: vec3f, moon: vec3f, moonDir: vec3f };
    
    fn lights() -> Lights {
      return Lights(scene.sunColor.rgb * scene.sunDir.w, scene.sunDir.xyz, vec3f(0.55, 0.64, 0.9) * 0.55 * scene.moonDir.w, scene.moonDir.xyz);
    }
    
    fn ambient(n: vec3f) -> vec3f {
      let skyLight = scene.zenith.rgb * 0.55 + scene.horizon.rgb * 0.45;
      let bounce = scene.ground.rgb * 0.6 + scene.horizon.rgb * 0.08;
      let moon = vec3f(0.45, 0.55, 0.85) * 0.05 * scene.moonDir.w * max(dot(n, scene.moonDir.xyz) * 0.5 + 0.5, 0.0);
      return mix(bounce, skyLight, n.y * 0.5 + 0.5) + moon;
    }
  • grass.wgslmodule Meadow grass for /carillon: lens, wind, clumps, blade shading, the far canopy and blades traced in a layer. 182 lignes
    Fichier
    src/carillon/grass.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    0 liaison
    // Meadow grass for /carillon: lens, wind, clumps, blade shading, the far canopy and blades traced in a layer.
    
    /** The meadow is a real ground plane under the chime, with grass on it. */
    const GROUND: f32 = -2.1;
    /** Focus distance and aperture of the virtual lens: the chime is sharp, the garden is not. */
    const FOCUS: f32 = 3.1;
    const APERTURE: f32 = 0.022;
    /** Meadow grass: blade height, the cell each clump of four blades roots in, how far blades are traced. */
    const GRASS_HEIGHT: f32 = 0.62;
    const GRASS_CELL: f32 = 0.16;
    const GRASS_REACH: f32 = 12.0;
    const GRASS_ROWS: i32 = 20;
    /** Angular size of a pixel, set by each pass's fs_main. */
    var<private> pixelFootprint: f32 = 0.0006;
    
    /** Blur radius (m) at distance t: the lens's circle of confusion, never finer than a pixel. */
    fn circleOfConfusion(t: f32) -> f32 { return APERTURE * abs(t / FOCUS - 1.0) + pixelFootprint * t * 0.6; }
    
    /** How far the grass leans at a point (0..1): fronts of wind rolling over the field, and a finer ripple. */
    fn windBend(p: vec2f) -> f32 {
      let dir = normalize(scene.wind.xz + vec2f(1e-3, 0.0));
      let speed = length(scene.wind.xz);
      let clock = scene.wind.w * scene.look.w;
      let along = dot(p, dir);
      let across = dot(p, vec2f(-dir.y, dir.x));
      let fronts = noise2(vec2f(along * 0.35 - clock * (0.8 + 0.35 * speed), across * 0.12));
      let ripple = 0.5 + 0.5 * sin(along * 2.2 - clock * (2.4 + 0.5 * speed) + noise2(p * 0.7) * 3.0);
      return clamp(fronts * 0.8 + ripple * 0.2, 0.0, 1.0) * clamp(0.25 + 0.3 * speed + 0.7 * scene.canopy.w, 0.0, 1.0);
    }
    
    /** Clumps of greener and drier grass: 0 fresh green, 1 straw. */
    fn clumpTone(p: vec2f) -> f32 {
      return clamp(0.18 + 0.5 * noise2(p * 0.07 + vec2f(5.0, 1.0)) + 0.34 * (noise2(p * 1.1) - 0.5), 0.0, 1.0);
    }
    
    struct GrassLight { sun: vec3f, sky: vec3f, glow: vec3f, sheen: vec3f, moon: vec3f };
    
    /** Light on the grass seen along d, shared by every blade of the pixel and by the far canopy. */
    fn grassLighting(d: vec3f) -> GrassLight {
      let light = lights();
      let level = normalize(vec3f(d.x, 0.0, d.z) + vec3f(0.0, 0.0, 1e-4));
      let sunLevel = normalize(vec3f(light.sunDir.x, 0.0, light.sunDir.z) + vec3f(0.0, 0.0, 1e-4));
      // +1 with the sun behind the viewer: lit faces, and the canopy's hot spot where no shadow shows.
      let facing = -dot(level, sunLevel);
      let height = clamp(light.sunDir.y * 2.5 + 0.35, 0.0, 1.0);
      let diffuse = (0.42 + 0.3 * facing) * (0.3 + 0.7 * height) * (1.0 + 0.3 * pow(max(facing, 0.0), 8.0));
      // Thin blades glow toward a low sun (after Barré-Brisebois and Bouchard's translucency lobe).
      let through = pow(clamp(dot(d, normalize(light.sunDir + vec3f(0.0, 0.3, 0.0))), 0.0, 1.0), 5.0) * (1.0 - 0.6 * height);
      let skyLight = scene.zenith.rgb * 0.55 + scene.horizon.rgb * 0.45;
      return GrassLight(light.sun * diffuse, skyLight, light.sun * through * 0.45, skyLight * 0.35 + light.sun * 0.05 * (0.4 + max(facing, 0.0)), light.moon * 0.35);
    }
    
    /** Blade albedo from its dark root to a straw tip, for a clump tone. */
    fn grassAlbedo(h: f32, tone: f32) -> vec3f {
      let body = mix(vec3f(0.07, 0.15, 0.03), vec3f(0.2, 0.17, 0.07), tone);
      let tip = mix(body, vec3f(0.3, 0.26, 0.13), 0.55);
      return mix(mix(body * 0.35, body, smoothstep(0.0, 0.45, h)), tip, smoothstep(0.7, 1.0, h));
    }
    
    /** Grass at relative height h: diffuse deeper in shade, the backlit glow, and the silvery sheen of bent blades. */
    fn grassShade(gl: GrassLight, h: f32, tone: f32, bend: f32) -> vec3f {
      let albedo = grassAlbedo(h, tone);
      let shade = mix(0.3, 1.0, h * h);
      let glowTint = mix(vec3f(0.35, 0.5, 0.08), vec3f(0.6, 0.45, 0.16), tone);
      return albedo * ((gl.sun + gl.moon) * shade + gl.sky * (0.4 + 0.6 * h))
        + gl.glow * glowTint * h * (0.6 + 0.4 * bend)
        + gl.sheen * bend * bend * h * 0.18;
    }
    
    /** The grass canopy seen from afar as one surface: what the blades show on average from that angle. */
    fn canopyColor(d: vec3f, p: vec2f, gl: GrassLight, t: f32) -> vec3f {
      let tone = clumpTone(p);
      let bend = windBend(p);
      // At grazing angles the eye meets the tops, seed heads and straw; looking down, the green depths.
      let grazing = smoothstep(0.35, 0.04, -d.y);
      var color = grassShade(gl, mix(0.62, 0.9, grazing), clamp(tone + 0.25 * grazing, 0.0, 1.0), bend);
      // Gaps between clumps show the darker depths, until distance averages them away.
      color *= 0.78 + 0.3 * noise2(p * 2.3) * (1.0 - smoothstep(8.0, 30.0, t));
      // Wind waves: the field brightens where the grass leans (GPU Gems 2, ch. 1).
      return color * (0.9 + 0.28 * bend);
    }
    
    /**
     * Meadow grass traced blade by blade. The ray crosses a layer of grass above the ground, walking rows of
     * cells along its dominant horizontal axis; in each row it tests every cell it passes over or near (within a
     * blade's lean and blur), so each blade is met exactly once, front to back, and no grid ever shows. A cell
     * roots four bent blades, some with a seed head or a flower. Coverage comes from each blade's closest
     * approach to the ray, widened by the circle of confusion: near blades are crisp, far ones melt into a
     * blurred field that hands over to the canopy surface. Returns the blades' colour, and how much of the
     * canopy behind still shows (darkened where the ray reached the shaded floor between the roots).
     */
    fn grassBlades(d: vec3f, gl: GrassLight, tTop: f32, tGround: f32) -> vec4f {
      let eye = scene.eye.xyz;
      let alongX = abs(d.x) > abs(d.z);
      let major = select(d.z, d.x, alongX);
      let minor = select(d.x, d.z, alongX);
      let eyeMajor = select(eye.z, eye.x, alongX);
      let eyeMinor = select(eye.x, eye.z, alongX);
      let forward = major > 0.0;
      let lastRow = floor((eyeMajor + major * tGround) / GRASS_CELL);
      var row = floor((eyeMajor + major * tTop) / GRASS_CELL);
      let margin = GRASS_CELL * 0.3 + 0.045;
      let windDir = normalize(scene.wind.xz + vec2f(1e-3, 0.0));
      // Wind and clump tone vary over metres: one value per pixel is enough, taken where the ray enters.
      let entry = eye.xz + d.xz * tTop;
      let bend = windBend(entry);
      let clump = clumpTone(entry);
      let windLean = windDir * bend * 0.035;
      // Seen from above, a blade and its head never stray from the root by more than their lean.
      let flat = d.xz / max(length(d.xz), 1e-5);
      let leanBound = 0.0285 + length(windLean);
      var color = vec3f(0.0);
      var transmit = 1.0;
      for (var i = 0; i < GRASS_ROWS; i++) {
        // Where the ray enters and leaves this row, inside the grass layer.
        let near = row * GRASS_CELL + select(GRASS_CELL, 0.0, forward);
        let far = row * GRASS_CELL + select(0.0, GRASS_CELL, forward);
        let tA = clamp((near - eyeMajor) / major, tTop, tGround);
        let tB = clamp((far - eyeMajor) / major, tTop, tGround);
        let m0 = eyeMinor + minor * tA;
        let m1 = eyeMinor + minor * tB;
        let c0 = floor((min(m0, m1) - margin) / GRASS_CELL);
        let c1 = floor((max(m0, m1) + margin) / GRASS_CELL);
        // Farther from the ray's track than lean, blur and the widest head, a blade cannot touch the pixel.
        let reach = leanBound + min(max(circleOfConfusion(tA), circleOfConfusion(tB)), GRASS_CELL * 0.3) + 0.016;
        for (var j = 0; j < 4; j++) {
          let c = select(c0 + f32(j), c1 - f32(j), minor < 0.0);
          if (c < c0 || c > c1) { break; }
          let cell = select(vec2f(c, row), vec2f(row, c), alongX);
          let middle = (cell + 0.5) * GRASS_CELL - eye.xz;
          if (abs(middle.x * flat.y - middle.y * flat.x) > reach + GRASS_CELL * 0.7072) { continue; }
          let tone = clamp(clump + (hash21(cell + 0.37) - 0.5) * 0.35, 0.0, 1.0);
          for (var k = 0; k < 4; k++) {
            let key = cell + vec2f(f32(k) * 17.13, f32(k) * 5.71);
            let r1 = hash22(key);
            let root = (cell + r1) * GRASS_CELL;
            let offset = root - eye.xz;
            if (abs(offset.x * flat.y - offset.y * flat.x) > reach) { continue; }
            let r2 = hash22(key + 41.7);
            let height = GRASS_HEIGHT * (0.55 + 0.45 * r2.x);
            let lean = (r2 - 0.5) * 0.04 + windLean * (height / GRASS_HEIGHT);
            let tip = vec3f(root.x + lean.x, GROUND + height, root.y + lean.y);
            let s = raySegment(eye, d, vec3f(root.x, GROUND, root.y), tip);
            // The blur is capped: wider, it would need blades from beyond the walk's margin.
            let blur = min(circleOfConfusion(s.y), GRASS_CELL * 0.3);
            let kind = hash21(key + 9.3);
            // Most blades pass far from the ray: skip them before any shading.
            if (s.x > 0.02 + blur && kind <= 0.86) { continue; }
            let radius = mix(0.005, 0.0007, pow(s.z, 1.4)) * (0.75 + 0.5 * r1.x);
            var alpha = clamp((radius + blur - s.x) / (2.0 * blur), 0.0, 1.0) * min(1.0, 1.7 * radius / blur);
            var shade = vec3f(0.0);
            if (alpha > 0.002) { shade = grassShade(gl, s.z * height / GRASS_HEIGHT, clamp(tone + (r1.y - 0.5) * 0.3, 0.0, 1.0), bend); }
            // A seed head at the tip, and now and then a flower.
            if (kind > 0.86) {
              let oc = tip - eye;
              let along = dot(oc, d);
              let headRadius = select(0.008, 0.014, kind > 0.985);
              let headBlur = min(circleOfConfusion(along), GRASS_CELL * 0.3);
              let head = clamp((headRadius + headBlur - length(oc - d * along)) / (2.0 * headBlur), 0.0, 1.0) * min(1.0, 1.7 * headRadius / headBlur);
              if (head > alpha) {
                alpha = head;
                shade = grassShade(gl, 1.0, 0.85, bend);
                if (kind > 0.985) {
                  let pick = hash21(key + 3.1);
                  var petals = vec3f(0.8, 0.78, 0.7);
                  if (pick > 0.92) { petals = vec3f(0.7, 0.07, 0.04); } else if (pick > 0.66) { petals = vec3f(0.85, 0.65, 0.1); }
                  shade = (petals * ((gl.sun + gl.moon) * 0.9 + gl.sky * 0.8) + gl.glow * petals * 0.5) * (1.0 - 0.5 * scene.horizon.w);
                }
              }
            }
            color += transmit * alpha * shade;
            transmit *= 1.0 - alpha;
          }
        }
        if (transmit < 0.02) { return vec4f(color, 0.0); }
        // Through to the ground: the shaded floor of the meadow, between the roots.
        if (row == lastRow) { return vec4f(color, transmit * 0.3); }
        row += select(-1.0, 1.0, forward);
      }
      // Beyond the walk the grass is dense enough to read as one surface.
      return vec4f(color, transmit);
    }
  • grass-pass.wgslfragment Half-resolution pass: meadow grass traced blade by blade where it is near enough to matter. rgb: the blades' colour; a: how much of the canopy surface behind them still shows. 12 lignes
    Fichier
    src/carillon/grass-pass.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    0 liaison
    // Half-resolution pass: meadow grass traced blade by blade where it is near enough to matter.
    // rgb: the blades' colour; a: how much of the canopy surface behind them still shows.
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let d = rayDirection(uv);
      if (d.y > -1e-4) { return vec4f(0.0, 0.0, 0.0, 1.0); }
      let tTop = (GROUND + GRASS_HEIGHT - scene.eye.y) / d.y;
      if (tTop >= GRASS_REACH) { return vec4f(0.0, 0.0, 0.0, 1.0); }
      // This pass runs at half resolution: its pixels are twice as wide.
      pixelFootprint = length(rayDirection(uv + vec2f(2.0 / scene.viewport.x, 0.0)) - d);
      return grassBlades(d, grassLighting(d), tTop, (GROUND - scene.eye.y) / d.y);
    }
  • lantern.wgslmodule The stone lantern (tōrō) out in the meadow: its shape as a distance field, its stone and flame, and a defocused trace of it for the half-resolution lantern pass. The scene pass reads its light level too. 109 lignes
    Fichier
    src/carillon/lantern.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    0 liaison
    // The stone lantern (tōrō) out in the meadow: its shape as a distance field, its stone and flame, and a
    // defocused trace of it for the half-resolution lantern pass. The scene pass reads its light level too.
    
    /** A tōrō out in the meadow, its foot in the grass; it is lit as dusk turns to night. */
    const LANTERN = vec3f(-5.1, GROUND, -6.45);
    const FLAME = vec3f(1.0, 0.56, 0.22);
    
    /** Hexagonal prism along y: apothem and half height (after Inigo Quilez). */
    fn sdHexPrism(p: vec3f, apothem: f32, half: f32) -> f32 {
      let k = vec3f(-0.8660254, 0.5, 0.57735);
      var q = abs(vec3f(p.x, p.z, p.y));
      q = vec3f(q.xy - 2.0 * min(dot(k.xy, q.xy), 0.0) * k.xy, q.z);
      let d = vec2f(length(q.xy - vec2f(clamp(q.x, -k.z * apothem, k.z * apothem), apothem)) * sign(q.y - apothem), q.z - half);
      return min(max(d.x, d.y), 0.0) + length(max(d, vec2f(0.0)));
    }
    
    fn sdCylinderY(p: vec3f, r: f32, half: f32) -> f32 {
      let d = abs(vec2f(length(p.xz), p.y)) - vec2f(r, half);
      return min(max(d.x, d.y), 0.0) + length(max(d, vec2f(0.0)));
    }
    
    /** Capped cone along y, radius r1 at -half and r2 at +half (after Inigo Quilez). */
    fn sdConeY(p: vec3f, half: f32, r1: f32, r2: f32) -> f32 {
      let q = vec2f(length(p.xz), p.y);
      let k1 = vec2f(r2, half);
      let k2 = vec2f(r2 - r1, 2.0 * half);
      let ca = vec2f(q.x - min(q.x, select(r2, r1, q.y < 0.0)), abs(q.y) - half);
      let cb = q - k1 + k2 * clamp(dot(k1 - q, k2) / dot(k2, k2), 0.0, 1.0);
      let s = select(1.0, -1.0, cb.x < 0.0 && ca.y < 0.0);
      return s * sqrt(min(dot(ca, ca), dot(cb, cb)));
    }
    
    /** A Kasuga lantern, 1.4 m: hexagonal foot, a round shaft with its ring, the platform, the light box, a flared roof curling up at the corners, the jewel. */
    fn lanternSdf(p: vec3f) -> f32 {
      var d = sdHexPrism(p - vec3f(0.0, 0.07, 0.0), 0.24, 0.07);
      d = min(d, sdCylinderY(p - vec3f(0.0, 0.42, 0.0), 0.07, 0.3));
      d = min(d, sdCylinderY(p - vec3f(0.0, 0.42, 0.0), 0.086, 0.018));
      d = min(d, sdConeY(p - vec3f(0.0, 0.71, 0.0), 0.02, 0.09, 0.19));
      d = min(d, sdHexPrism(p - vec3f(0.0, 0.77, 0.0), 0.19, 0.04));
      d = min(d, sdHexPrism(p - vec3f(0.0, 0.94, 0.0), 0.13, 0.13));
      d = min(d, sdHexPrism(p - vec3f(0.0, 1.085, 0.0), 0.3, 0.018));
      d = min(d, sdConeY(p - vec3f(0.0, 1.16, 0.0), 0.06, 0.28, 0.06));
      // Each corner of the roof curls up (warabite): fold the angle onto the nearest corner.
      let corner = round(atan2(p.z, p.x) / (PI / 3.0)) * (PI / 3.0);
      d = min(d, length(p - vec3f(cos(corner) * 0.335, 1.12, sin(corner) * 0.335)) - 0.026);
      d = min(d, length(p - vec3f(0.0, 1.29, 0.0)) - 0.05);
      d = min(d, sdConeY(p - vec3f(0.0, 1.37, 0.0), 0.04, 0.03, 0.0));
      return d;
    }
    
    /** How brightly the lantern burns: out by day, lit as dusk deepens, with a candle's flicker. */
    fn lanternLevel() -> f32 {
      let flicker = 0.88 + 0.12 * (noise2(vec2f(scene.eye.w * 6.0, 3.7)) * 2.0 - 1.0) * scene.look.w;
      return smoothstep(0.06, 0.4, scene.horizon.w) * flicker;
    }
    
    /** Weathered granite with moss on the tops; dark windows by day, the flame behind them by night. */
    fn shadeLantern(q: vec3f, n: vec3f) -> vec3f {
      let light = lights();
      let grain = noise3(q * 14.0);
      var albedo = vec3f(0.36, 0.35, 0.32) * (0.72 + 0.5 * grain);
      let moss = smoothstep(0.4, 0.85, n.y) * smoothstep(0.42, 0.7, noise3(q * 6.0 + 2.0));
      albedo = mix(albedo, vec3f(0.11, 0.15, 0.05), moss * 0.85);
      let level = lanternLevel();
      var color = albedo * (ambient(n) + light.sun * max(dot(n, light.sunDir), 0.0) * 0.95 + light.moon * max(dot(n, light.moonDir), 0.0) * 0.6);
      color += albedo * FLAME * level * 0.6 * smoothstep(0.35, 0.05, length(q - vec3f(0.0, 0.94, 0.0)));
      if (q.y > 0.84 && q.y < 1.04) {
        let a = atan2(q.z, q.x);
        let face = round((a - PI / 6.0) / (PI / 3.0)) * (PI / 3.0) + PI / 6.0;
        let r = length(q.xz);
        let window = step(abs(r * sin(a - face)), 0.048) * step(0.118, r * cos(a - face)) * smoothstep(0.85, 0.87, q.y) * smoothstep(1.03, 1.01, q.y);
        color = mix(color, color * 0.12 + FLAME * level * 3.0, window);
      }
      return color;
    }
    
    /**
     * The lantern along a ray, out of focus like the garden: the march keeps the closest approach relative to
     * the lens blur there, against a surface sunk by half that blur, so the soft edge is centred on the true one.
     */
    fn lantern(ro: vec3f, rd: vec3f) -> vec4f {
      let axis = raySegment(ro, rd, LANTERN, LANTERN + vec3f(0.0, 1.42, 0.0));
      // Only the roof reaches 0.36 from the axis; below it the stone stays within 0.25.
      let reach = select(0.25, 0.36, axis.z > 0.72) + 2.0 * circleOfConfusion(axis.y);
      if (axis.x > reach) { return vec4f(0.0); }
      var t = max(axis.y - 0.45, 0.05);
      let end = axis.y + 0.45;
      var closest = 1e9;
      var at = t;
      for (var i = 0; i < 64; i++) {
        let d = lanternSdf(ro + rd * t - LANTERN);
        let blur = circleOfConfusion(t);
        let ratio = (d + 0.5 * blur) / blur;
        if (ratio < closest) { closest = ratio; at = t; }
        if (closest <= 0.0 || t > end) { break; }
        t += max(d * 0.9, 0.12 * blur);
      }
      var alpha = clamp(1.0 - closest, 0.0, 1.0);
      if (alpha < 0.004) { return vec4f(0.0); }
      let q = ro + rd * at - LANTERN;
      // The meadow hides its foot.
      alpha *= smoothstep(0.1, 0.45, q.y + (noise2(vec2f(q.x * 30.0 + q.z * 17.0, q.y * 8.0)) - 0.5) * 0.2);
      let e = vec2f(0.004, -0.004);
      let n = normalize(e.xyy * lanternSdf(q + e.xyy) + e.yyx * lanternSdf(q + e.yyx) + e.yxy * lanternSdf(q + e.yxy) + e.xxx * lanternSdf(q + e.xxx));
      // As hazy as the meadow around it; premultiplied, so the half-resolution image filters cleanly.
      let fog = 1.0 - exp(-at * (0.006 + 0.01 * scene.ground.w));
      let haze = mix(scene.ground.rgb * 1.25, scene.horizon.rgb * 0.3, 0.5);
      return vec4f(mix(shadeLantern(q, n), haze, fog) * alpha, alpha);
    }
  • lantern-pass.wgslfragment Half-resolution pass: the stone lantern, out of focus in the meadow. rgb: its colour premultiplied by coverage; a: coverage. 9 lignes
    Fichier
    src/carillon/lantern-pass.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    0 liaison
    // Half-resolution pass: the stone lantern, out of focus in the meadow.
    // rgb: its colour premultiplied by coverage; a: coverage.
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let d = rayDirection(uv);
      // This pass runs at half resolution: its pixels are twice as wide.
      pixelFootprint = length(rayDirection(uv + vec2f(2.0 / scene.viewport.x, 0.0)) - d);
      return lantern(scene.eye.xyz, d);
    }
  • foreground-pass.wgslfragment Half-resolution pass: tall grasses a step in front of the lens, blurred into soft strokes. rgb: their colour; a: how much of the scene behind still shows. 56 lignes
    Fichier
    src/carillon/foreground-pass.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    0 liaison
    // Half-resolution pass: tall grasses a step in front of the lens, blurred into soft strokes.
    // rgb: their colour; a: how much of the scene behind still shows.
    
    /** Tall grasses a step in front of the viewer: root (x, z), height, and a seed each. */
    const STEMS = array<vec4f, 9>(
      vec4f(-0.014, 2.197, 1.228, 0.13), vec4f(-0.049, 2.001, 1.265, 0.41), vec4f(0.067, 2.268, 1.217, 0.67),
      vec4f(0.004, 1.833, 1.3, 0.29), vec4f(0.083, 2.107, 1.238, 0.83), vec4f(0.118, 2.195, 1.2, 0.55),
      vec4f(0.125, 1.892, 1.245, 0.37), vec4f(0.684, 2.028, 1.238, 0.71), vec4f(0.747, 1.84, 1.281, 0.19),
    );
    
    /**
     * They bow downwind and sway on their own. So close, the lens melts each into a faint soft stroke: mostly a
     * darker veil over the meadow, with a thin golden rim when the sun is low behind them.
     */
    fn foreground(ro: vec3f, rd: vec3f) -> vec4f {
      var color = vec3f(0.0);
      var through = 1.0;
      let light = lights();
      let wind = scene.wind.xz;
      let speed = length(wind);
      let crosswind = normalize(vec2f(-wind.y, wind.x) + vec2f(1e-4, 0.0));
      let back = pow(clamp(dot(rd, light.sunDir), 0.0, 1.0), 6.0);
      // All of them stand between 1.8 and 2.3 m out along z, their tips below -0.72: a ray still above that
      // height across the whole band misses every one.
      let tNear = (2.3 - ro.z) / min(rd.z, -1e-4);
      let tFar = (1.8 - ro.z) / min(rd.z, -1e-4);
      if (tFar <= 0.0 || min(ro.y + rd.y * max(tNear, 0.0), ro.y + rd.y * tFar) > -0.72) { return vec4f(0.0, 0.0, 0.0, 1.0); }
      for (var i = 0u; i < 9u; i++) {
        let stem = STEMS[i];
        let root = vec3f(stem.x, GROUND, stem.y);
        let sway = sin(scene.wind.w * (1.3 + stem.w) + stem.w * 20.0) * (0.015 + 0.012 * speed) * scene.look.w;
        let lean = wind * (0.03 + 0.05 * scene.canopy.w + sway * 0.6) + crosswind * sway + vec2f(0.04 * (stem.w - 0.5), 0.0);
        let tip = root + vec3f(lean.x, stem.z, lean.y);
        let mid = root + vec3f(lean.x * 0.3, stem.z * 0.55, lean.y * 0.3);
        let s = raySegment(ro, rd, mid, tip);
        if (s.y <= 0.05) { continue; }
        let blur = circleOfConfusion(s.y);
        if (s.x > 0.006 + blur) { continue; }
        // A slender stem, then the seed head along its last fifth, tapering to the tip.
        let head = smoothstep(0.76, 0.82, s.z);
        let radius = mix(0.0016, 0.006 * (1.0 - 0.6 * smoothstep(0.9, 1.0, s.z)), head);
        let alpha = min(clamp((radius + blur - s.x) / (2.0 * blur), 0.0, 1.0) * radius / (radius + blur) * 1.5, 0.55);
        if (alpha < 0.003) { continue; }
        let paint = mix(vec3f(0.07, 0.08, 0.035), vec3f(0.2, 0.17, 0.08), head);
        let shade = paint * (light.sun * 0.18 + ambient(vec3f(0.0, 1.0, 0.0)) * 0.7 + light.moon * 0.2) + light.sun * paint * vec3f(1.0, 0.85, 0.5) * back * 1.1;
        color = color * (1.0 - alpha) + shade * alpha;
        through *= 1.0 - alpha;
      }
      return vec4f(color, through);
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let d = rayDirection(uv);
      pixelFootprint = length(rayDirection(uv + vec2f(2.0 / scene.viewport.x, 0.0)) - d);
      return foreground(scene.eye.xyz, d);
    }
  • scene.wgslfragment The whole picture, ray traced per pixel with analytic intersections: six brushed-aluminium tubes, a wooden plate, clapper and wind sail on waxed cords, a paper tanzaku under the sail, the apple branch they hang from (a short sphere-traced SDF), its leaves and apples, leaves and petals on the wind, fireflies at night, and a defocused evening garden behind everything, with the stone lantern and the tall grass in front of the lens traced in their own half-resolution passes. Intersectors after Inigo Quilez, https://iquilezles.org/articles/intersectors/. 964 lignes
    Fichier
    src/carillon/scene.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    7 liaisons
    // The whole picture, ray traced per pixel with analytic intersections: six
    // brushed-aluminium tubes, a wooden plate, clapper and wind sail on waxed cords,
    // a paper tanzaku under the sail, the apple branch they hang from (a short
    // sphere-traced SDF), its leaves and apples, leaves and petals on the wind,
    // fireflies at night, and a defocused evening garden behind everything, with
    // the stone lantern and the tall grass in front of the lens traced in their own
    // half-resolution passes.
    // Intersectors after Inigo Quilez, https://iquilezles.org/articles/intersectors/.
    
    @group(0) @binding(1) var<storage, read> bodies: array<vec4f>;
    @group(0) @binding(2) var grassImage: texture_2d<f32>;
    @group(0) @binding(3) var grassSampler: sampler;
    @group(0) @binding(4) var inkImage: texture_2d<f32>;
    @group(0) @binding(5) var inkSampler: sampler;
    // Layers traced at half resolution, as blurred as the lens makes them: the lantern (premultiplied colour,
    // coverage) behind the chime, and the tall grass in front of the lens (colour, how much shows through).
    @group(0) @binding(6) var lanternImage: texture_2d<f32>;
    @group(0) @binding(7) var foregroundImage: texture_2d<f32>;
    
    // Body layout written by pack.ts.
    const TUBE_AXES: u32 = TUBES;
    const CLAPPER: u32 = 12u;
    const PLATE: u32 = 14u;
    const SAIL: u32 = 16u;
    const STRING_BASE: u32 = 20u;
    
    const M_NONE: u32 = 0u;
    const M_TUBE: u32 = 1u;
    const M_RIM: u32 = 2u;
    const M_BORE: u32 = 3u;
    const M_PLATE: u32 = 4u;
    const M_CLAPPER: u32 = 5u;
    const M_SAIL: u32 = 6u;
    const M_BARK: u32 = 7u;
    const M_LEAF: u32 = 8u;
    const M_APPLE: u32 = 9u;
    const M_KNOT: u32 = 10u;
    const M_PAPER: u32 = 11u;
    
    /** Brushed, slightly oxidised tubes reflect less than polished metal, over a grey sheen. */
    const BRUSHED = vec3f(0.5, 0.515, 0.53);
    const OXIDE = vec3f(0.17, 0.175, 0.18);
    
    struct Hit { t: f32, n: vec3f, material: u32, id: u32, local: vec3f };
    
    fn noHit() -> Hit { return Hit(1e9, vec3f(0.0, 1.0, 0.0), M_NONE, 0u, vec3f(0.0)); }
    
    
    // ---------------------------------------------------------------- intersectors
    
    /** Capped cylinder a–b: (t, normal), t < 0 on a miss. */
    fn iCylinder(ro: vec3f, rd: vec3f, a: vec3f, b: vec3f, ra: f32) -> vec4f {
      let ba = b - a;
      let oc = ro - a;
      let baba = dot(ba, ba);
      let bard = dot(ba, rd);
      let baoc = dot(ba, oc);
      let k2 = baba - bard * bard;
      let k1 = baba * dot(oc, rd) - baoc * bard;
      let k0 = baba * dot(oc, oc) - baoc * baoc - ra * ra * baba;
      var h = k1 * k1 - k2 * k0;
      if (h < 0.0) { return vec4f(-1.0); }
      h = sqrt(h);
      var t = (-k1 - h) / k2;
      let y = baoc + t * bard;
      if (y > 0.0 && y < baba) { return vec4f(t, (oc + t * rd - ba * y / baba) / ra); }
      t = (select(baba, 0.0, y < 0.0) - baoc) / bard;
      if (abs(k1 + k2 * t) < h) { return vec4f(t, ba * sign(y) / sqrt(baba)); }
      return vec4f(-1.0);
    }
    
    fn iSphere(ro: vec3f, rd: vec3f, center: vec3f, r: f32) -> f32 {
      let oc = ro - center;
      let b = dot(oc, rd);
      let h = b * b - dot(oc, oc) + r * r;
      if (h < 0.0) { return -1.0; }
      return -b - sqrt(h);
    }
    
    /** Oriented box given its centre, unit axes and half extents: (t, normal). */
    fn iBox(ro: vec3f, rd: vec3f, center: vec3f, ax: vec3f, ay: vec3f, az: vec3f, half: vec3f) -> vec4f {
      let d = ro - center;
      let o = vec3f(dot(d, ax), dot(d, ay), dot(d, az));
      let r = vec3f(dot(rd, ax), dot(rd, ay), dot(rd, az));
      let m = 1.0 / r;
      let n = m * o;
      let k = abs(m) * half;
      let t1 = -n - k;
      let t2 = -n + k;
      let tN = max(max(t1.x, t1.y), t1.z);
      let tF = min(min(t2.x, t2.y), t2.z);
      if (tN > tF || tF < 0.0) { return vec4f(-1.0); }
      let nl = -sign(r) * step(t1.yzx, t1.xyz) * step(t1.zxy, t1.xyz);
      return vec4f(tN, nl.x * ax + nl.y * ay + nl.z * az);
    }
    
    
    // ---------------------------------------------------------------- the chime
    
    fn tubeCenter(i: u32) -> vec4f { return bodies[i]; }
    fn tubeAxis(i: u32) -> vec4f { return bodies[TUBE_AXES + i]; }
    
    /** Tubes first; with `skip` < 100 (a reflection off tube `skip`) only the other tubes. */
    fn traceChime(ro: vec3f, rd: vec3f, skip: u32) -> Hit {
      var best = noHit();
      for (var i = 0u; i < TUBES; i++) {
        if (i == skip) { continue; }
        let c = tubeCenter(i);
        let a = tubeAxis(i);
        let top = c.xyz + a.xyz * c.w;
        let hit = iCylinder(ro, rd, top, c.xyz - a.xyz * c.w, a.w);
        if (hit.x > 0.0 && hit.x < best.t) {
          let p = ro + rd * hit.x;
          let along = dot(top - p, a.xyz) / (2.0 * c.w);
          var material = M_TUBE;
          if (abs(dot(hit.yzw, a.xyz)) > 0.9) {
            let radial = length((p - c.xyz) - a.xyz * dot(p - c.xyz, a.xyz));
            material = select(M_RIM, M_BORE, radial < TUBE_INNER);
          }
          best = Hit(hit.x, normalize(hit.yzw), material, i, vec3f(along, 0.0, 0.0));
        }
      }
      if (skip < 100u) { return best; }
      // Wooden parts: plate and clapper discs, the sail board.
      for (var k = 0u; k < 2u; k++) {
        let index = select(PLATE, CLAPPER, k == 1u);
        let c = bodies[index];
        let a = bodies[index + 1u];
        let hit = iCylinder(ro, rd, c.xyz + a.xyz * c.w, c.xyz - a.xyz * c.w, a.w);
        if (hit.x > 0.0 && hit.x < best.t) {
          best = Hit(hit.x, normalize(hit.yzw), select(M_PLATE, M_CLAPPER, k == 1u), index, ro + rd * hit.x - c.xyz);
        }
      }
      let sc = bodies[SAIL];
      let sx = bodies[SAIL + 1u];
      let sy = bodies[SAIL + 2u];
      let sz = bodies[SAIL + 3u];
      let box = iBox(ro, rd, sc.xyz, sx.xyz, sy.xyz, sz.xyz, vec3f(sx.w, sy.w, sc.w));
      if (box.x > 0.0 && box.x < best.t) {
        let d = ro + rd * box.x - sc.xyz;
        let local = vec3f(dot(d, sx.xyz), dot(d, sy.xyz), dot(d, sz.xyz));
        // A rounded paddle: narrower at the top where the cord ties on.
        let y = local.y / sy.w;
        let halfWidth = sx.w * (0.62 + 0.3 * smoothstep(1.0, -0.2, y)) * sqrt(max(1.0 - pow(max(abs(y) - 0.55, 0.0) / 0.45, 2.0), 0.0));
        if (abs(local.x) < halfWidth) { best = Hit(box.x, box.yzw, M_SAIL, SAIL, local); }
      }
      // The brass ring where the bridle meets the hanging cord.
      let knot = iSphere(ro, rd, vec3f(0.0, BRIDLE, 0.0), 0.012);
      if (knot > 0.0 && knot < best.t) {
        best = Hit(knot, normalize(ro + rd * knot - vec3f(0.0, BRIDLE, 0.0)), M_KNOT, 0u, vec3f(0.0));
      }
      let paper = traceTanzaku(ro, rd);
      if (paper.t < best.t) { best = paper; }
      return best;
    }
    
    /** The tanzaku: a flat paper strip hanging from its cord under the sail. */
    fn traceTanzaku(ro: vec3f, rd: vec3f) -> Hit {
      let top = bodies[TANZAKU_AT];
      let along = bodies[TANZAKU_AT + 1u];
      let across = bodies[TANZAKU_AT + 2u].xyz;
      let n = cross(across, along.xyz);
      let facing = dot(rd, n);
      if (abs(facing) < 1e-5) { return noHit(); }
      let t = dot(top.xyz - ro, n) / facing;
      if (t <= 0.0) { return noHit(); }
      let q = ro + rd * t - top.xyz;
      let u = dot(q, across) / top.w;
      let v = dot(q, along.xyz) / along.w;
      if (abs(u) > 1.0 || v < 0.0 || v > 1.0) { return noHit(); }
      // local: across (-1..1), down the strip (0..1), and which face shows (+1 the written one).
      return Hit(t, select(n, -n, facing > 0.0), M_PAPER, 0u, vec3f(u, v, select(1.0, -1.0, facing > 0.0)));
    }
    
    // ---------------------------------------------------------------- branch, leaves, apple
    
    // Tapered segments of the apple branch: (a.xyz, radius at a), (b.xyz, radius at b). The first one
    // runs back toward the trunk, far enough that its cut end stays out of every view the camera allows.
    const BRANCH = array<vec4f, 24>(
      vec4f(-6.8, 2.2, -2.6, 0.125), vec4f(-3.4, 1.05, -1.3, 0.095),
      vec4f(-3.4, 1.05, -1.3, 0.095), vec4f(-2.3, 0.74, -0.78, 0.078),
      vec4f(-2.3, 0.74, -0.78, 0.078), vec4f(-1.45, 0.52, -0.42, 0.063),
      vec4f(-1.45, 0.52, -0.42, 0.063), vec4f(-0.7, 0.4, -0.15, 0.05),
      vec4f(-0.7, 0.4, -0.15, 0.05), vec4f(0.0, 0.335, 0.0, 0.039),
      vec4f(0.0, 0.335, 0.0, 0.039), vec4f(0.62, 0.315, 0.1, 0.03),
      vec4f(0.62, 0.315, 0.1, 0.03), vec4f(1.15, 0.36, 0.2, 0.02),
      vec4f(1.15, 0.36, 0.2, 0.02), vec4f(1.62, 0.5, 0.3, 0.009),
      vec4f(-0.92, 0.43, -0.21, 0.022), vec4f(-0.74, 0.66, -0.04, 0.008),
      vec4f(0.6, 0.315, 0.1, 0.014), vec4f(0.88, 0.19, 0.3, 0.006),
      vec4f(-0.22, 0.35, -0.03, 0.016), vec4f(-0.08, 0.54, -0.26, 0.006),
      vec4f(1.1, 0.35, 0.19, 0.012), vec4f(1.25, 0.56, 0.05, 0.005),
    );
    const BRANCH_SEGMENTS: u32 = 12u;
    
    fn smin(a: f32, b: f32, k: f32) -> f32 {
      let h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
      return mix(b, a, h) - k * h * (1.0 - h);
    }
    
    fn branchSdf(p: vec3f) -> f32 {
      var d = 1e9;
      for (var i = 0u; i < BRANCH_SEGMENTS; i++) {
        let a = BRANCH[2u * i];
        let b = BRANCH[2u * i + 1u];
        let ba = b.xyz - a.xyz;
        let h = clamp(dot(p - a.xyz, ba) / dot(ba, ba), 0.0, 1.0);
        d = smin(d, length(p - a.xyz - ba * h) - mix(a.w, b.w, h), 0.025);
      }
      // Bark: long furrows along the wood and a finer grain. They only ever sink the surface, by 4.3 mm at
      // most, so away from the wood the smooth distance is a safe step and the noise can wait.
      if (d > 0.01) { return d; }
      let furrow = noise3(p * vec3f(38.0, 90.0, 90.0)) * 0.0035 + noise3(p * 260.0) * 0.0008;
      return d + furrow;
    }
    
    fn traceBranch(ro: vec3f, rd: vec3f, tMax: f32) -> Hit {
      // Bound every segment by a padded capsule first; most pixels never march.
      var t0 = 1e9;
      var t1 = -1.0;
      for (var i = 0u; i < BRANCH_SEGMENTS; i++) {
        let a = BRANCH[2u * i];
        let b = BRANCH[2u * i + 1u];
        let s = raySegment(ro, rd, a.xyz, b.xyz);
        let r = max(a.w, b.w) + 0.03;
        if (s.x < r) {
          let depth = sqrt(r * r - s.x * s.x);
          t0 = min(t0, s.y - depth - 0.05);
          t1 = max(t1, s.y + depth + 0.05);
        }
      }
      if (t1 < 0.0) { return noHit(); }
      var t = max(t0, 0.0);
      let end = min(t1, tMax);
      for (var i = 0; i < 64; i++) {
        if (t > end) { break; }
        let d = branchSdf(ro + rd * t);
        if (d < 0.0006 * max(t, 1.0)) {
          let p = ro + rd * t;
          let e = vec2f(0.0012, 0.0);
          let n = normalize(vec3f(branchSdf(p + e.xyy) - branchSdf(p - e.xyy), branchSdf(p + e.yxy) - branchSdf(p - e.yxy), branchSdf(p + e.yyx) - branchSdf(p - e.yyx)));
          return Hit(t, n, M_BARK, 0u, p);
        }
        t += d * 0.85;
      }
      return noHit();
    }
    
    // Leaves along the twigs: base point and a seed.
    const LEAF_COUNT: u32 = 40u;
    const LEAVES = array<vec4f, 40>(
      vec4f(-0.74, 0.66, -0.04, 0.05), vec4f(-0.77, 0.63, -0.07, 0.27), vec4f(-0.81, 0.58, -0.11, 0.43), vec4f(-0.7, 0.64, 0.0, 0.59), vec4f(-0.76, 0.68, -0.02, 0.83),
      vec4f(-0.08, 0.54, -0.26, 0.07), vec4f(-0.11, 0.5, -0.21, 0.37), vec4f(-0.15, 0.45, -0.15, 0.53), vec4f(-0.05, 0.55, -0.29, 0.77),
      vec4f(0.88, 0.19, 0.3, 0.13), vec4f(0.83, 0.21, 0.26, 0.31), vec4f(0.77, 0.24, 0.21, 0.47), vec4f(0.91, 0.17, 0.33, 0.66), vec4f(0.7, 0.27, 0.17, 0.97),
      vec4f(1.25, 0.56, 0.05, 0.19), vec4f(1.22, 0.52, 0.09, 0.39), vec4f(1.18, 0.47, 0.13, 0.61), vec4f(1.27, 0.57, 0.02, 0.87),
      vec4f(1.62, 0.5, 0.3, 0.21), vec4f(1.56, 0.48, 0.29, 0.41), vec4f(1.48, 0.45, 0.28, 0.57), vec4f(1.4, 0.43, 0.26, 0.73), vec4f(1.66, 0.52, 0.31, 0.93),
      vec4f(-1.2, 0.48, -0.33, 0.23), vec4f(-1.05, 0.45, -0.27, 0.51), vec4f(-0.95, 0.43, -0.23, 0.69), vec4f(-1.32, 0.5, -0.37, 0.89),
      vec4f(0.3, 0.32, 0.04, 0.33), vec4f(0.18, 0.33, 0.02, 0.67), vec4f(0.42, 0.315, 0.06, 0.15), vec4f(-0.35, 0.37, -0.07, 0.87),
      vec4f(-2.0, 0.66, -0.66, 0.11), vec4f(-1.9, 0.63, -0.62, 0.29), vec4f(-2.1, 0.69, -0.7, 0.47), vec4f(-1.75, 0.6, -0.56, 0.63),
      vec4f(1.0, 0.35, 0.18, 0.93), vec4f(0.98, 0.33, 0.17, 0.09), vec4f(-0.5, 0.39, -0.1, 0.35), vec4f(-0.58, 0.4, -0.12, 0.55), vec4f(0.52, 0.31, 0.08, 0.79),
    );
    
    struct Leaf { base: vec3f, along: vec3f, across: vec3f, normal: vec3f, size: f32 };
    
    fn leafFrame(i: u32) -> Leaf {
      let data = LEAVES[i];
      let seed = data.w;
      // Petiole direction: out and down from the twig, then fluttering in the wind.
      let yaw = seed * 37.0;
      let droop = -0.35 - 0.5 * hash11(seed * 13.0);
      var along = normalize(vec3f(cos(yaw), droop, sin(yaw)));
      let gust = length(scene.wind.xyz) * 0.12 + scene.canopy.w * 0.35;
      let clock = scene.wind.w;
      let flutter = (sin(clock * (4.0 + seed * 5.0) + seed * 40.0) * 0.5 + sin(clock * (9.0 + seed * 3.0)) * 0.2) * (0.12 + gust) * scene.look.w;
      var across = normalize(cross(along, vec3f(0.0, 1.0, 0.0)));
      var normal = normalize(cross(across, along));
      let roll = (hash11(seed * 7.0) - 0.5) * 1.6 + flutter;
      let c = cos(roll);
      let s = sin(roll);
      let rolled = normal * c + across * s;
      across = across * c - normal * s;
      normal = rolled;
      // A little lift toward the wind.
      along = normalize(along + scene.wind.xyz * 0.015 * scene.look.w);
      let side = normalize(cross(normal, along));
      return Leaf(data.xyz, along, side, normalize(cross(along, side)), 0.085 + 0.04 * hash11(seed * 3.0));
    }
    
    fn traceLeaves(ro: vec3f, rd: vec3f, tMax: f32) -> Hit {
      var best = noHit();
      let lo = (vec3f(-2.4, 0.05, -0.95) - ro) / rd;
      let hi = (vec3f(1.95, 0.85, 0.6) - ro) / rd;
      let enter = max(max(min(lo.x, hi.x), min(lo.y, hi.y)), min(lo.z, hi.z));
      let leave = min(min(max(lo.x, hi.x), max(lo.y, hi.y)), max(lo.z, hi.z));
      if (enter > leave || leave < 0.0 || enter > tMax) { return best; }
      best.t = tMax;
      for (var i = 0u; i < LEAF_COUNT; i++) {
        // No leaf reaches farther than 0.13 from its base: most are passed by before any trigonometry.
        let oc = LEAVES[i].xyz - ro;
        let reach = dot(oc, rd);
        if (reach <= -0.13 || reach - 0.13 >= best.t || length(oc - rd * reach) > 0.13) { continue; }
        let leaf = leafFrame(i);
        let denom = dot(rd, leaf.normal);
        if (abs(denom) < 1e-4) { continue; }
        let t = dot(leaf.base - ro, leaf.normal) / denom;
        if (t <= 0.0 || t >= best.t) { continue; }
        let q = ro + rd * t - leaf.base;
        let u = dot(q, leaf.along) / leaf.size;
        let v = dot(q, leaf.across) / leaf.size;
        // Apple leaf: an ellipse with a pointed tip, a short petiole at the base.
        let stem = 0.12;
        let x = (u - stem) / (1.0 - stem);
        let width = 0.3 * pow(max(sin(PI * clamp(x, 0.0, 1.0)), 0.0), 0.75) * (1.0 - 0.25 * x);
        let petiole = u > 0.0 && u < stem && abs(v) < 0.018;
        if ((x > 0.0 && x < 1.0 && abs(v) < width) || petiole) {
          best = Hit(t, leaf.normal, M_LEAF, i, vec3f(x, v / max(width, 1e-3), 0.0));
        }
      }
      if (best.material == M_NONE) { best.t = 1e9; }
      return best;
    }
    
    const APPLES = array<vec4f, 3>(
      vec4f(0.7, 0.215, 0.135, 0.036), vec4f(-0.63, 0.31, -0.1, 0.034), vec4f(1.2, 0.3, 0.2, 0.03),
    );
    
    fn traceApple(ro: vec3f, rd: vec3f, tMax: f32) -> Hit {
      var best = noHit();
      best.t = tMax;
      for (var i = 0u; i < 3u; i++) {
        let apple = APPLES[i];
        let t = iSphere(ro, rd, apple.xyz, apple.w);
        if (t > 0.0 && t < best.t) { best = Hit(t, normalize(ro + rd * t - apple.xyz), M_APPLE, i, ro + rd * t - apple.xyz); }
      }
      if (best.material == M_NONE) { best.t = 1e9; }
      return best;
    }
    
    // ---------------------------------------------------------------- the garden behind
    
    fn directionAngles(d: vec3f) -> vec2f { return vec2f(atan2(d.x, -d.z), asin(clamp(d.y, -1.0, 1.0))); }
    
    /** The horizon's colour: at dusk it burns toward the sun and cools away from it. */
    fn horizonTint(d: vec3f) -> vec3f {
      let level = normalize(vec3f(d.x, 0.0, d.z) + vec3f(0.0, 0.0, 1e-4));
      let sunLevel = normalize(vec3f(scene.sunDir.x, 0.0, scene.sunDir.z) + vec3f(0.0, 0.0, 1e-4));
      let toward = clamp(dot(level, sunLevel) * 0.5 + 0.5, 0.0, 1.0);
      let focus = scene.sunColor.w;
      let away = mix(scene.horizon.rgb, scene.zenith.rgb * 1.6 + scene.horizon.rgb * 0.25, focus * 0.8);
      return mix(away, scene.horizon.rgb * (1.0 + 0.35 * focus), pow(toward, 1.0 + 2.0 * focus));
    }
    
    fn cloudShape(plane: vec2f) -> f32 {
      return fbm2(plane * 0.8 + vec2f(3.1, 7.4)) + 0.22 * noise2(plane * 4.2) - 0.08;
    }
    
    /** Sky only: gradient, sun glow, a few soft clouds, stars and the moon. */
    fn sky(d: vec3f, withDiscs: bool) -> vec3f {
      let up = max(d.y, 0.0);
      let sunDir = scene.sunDir.xyz;
      let mu = max(dot(d, sunDir), 0.0);
      let horizon = horizonTint(d);
      var color = mix(scene.zenith.rgb, horizon, exp(-up * 6.5));
      color += scene.sunColor.rgb * (0.02 * pow(mu, 4.0) + 0.07 * pow(mu, 24.0) * (0.3 + exp(-up * 8.0)));
      // Clouds on a sheet above the garden, drifting with the wind.
      if (d.y > 0.0) {
        let plane = d.xz / (d.y + 0.12) * 1.3 + vec2f(scene.wind.w * 0.012, scene.wind.w * 0.004);
        let shape = cloudShape(plane);
        let density = smoothstep(0.55, 0.86, shape) * smoothstep(0.015, 0.12, d.y) * (1.0 - 0.5 * smoothstep(0.35, 0.8, d.y));
        if (density > 0.001) {
          // A cloud is brightest where it thins toward the sun: compare with a sample a step sunward.
          let sunward = normalize(scene.sunDir.xz + vec2f(1e-4, 0.0)) * 0.16;
          let lit = clamp((shape - cloudShape(plane + sunward)) * 5.0 + 0.45, 0.0, 1.0);
          let light = scene.sunColor.rgb * scene.sunDir.w * (0.1 + 0.55 * pow(mu, 4.0)) + horizon * 0.32;
          let shade = mix(scene.zenith.rgb, horizon, 0.3) * 0.55;
          var cloud = mix(shade, light, lit);
          // Silver lining on the thin edges, strongest close to the sun.
          cloud += scene.sunColor.rgb * scene.sunDir.w * (1.0 - density) * pow(mu, 10.0) * 0.9;
          cloud += vec3f(0.7, 0.78, 1.0) * scene.moonDir.w * 0.04 * (0.4 + lit) * pow(max(dot(d, scene.moonDir.xyz), 0.0), 6.0);
          color = mix(color, cloud, density * 0.85);
        }
      }
      if (!withDiscs) { return color; }
      // A defocused sun: a soft, bright disc that the far hills can hide, in its halo.
      color += scene.sunColor.rgb * 0.35 * pow(mu, 300.0);
      let sunDisc = smoothstep(0.99955, 0.99985, mu);
      let sunTint = scene.sunColor.rgb / max(max(scene.sunColor.r, scene.sunColor.g), 1e-3);
      color += (sunTint * 7.0 + vec3f(0.6)) * sunDisc * scene.sunDir.w;
      if (scene.look.x > 0.001) {
        let cell = floor(d * 190.0);
        let star = hash31(cell);
        if (star > 0.9965) {
          let center = (cell + 0.5) / 190.0;
          let glow = smoothstep(0.0055, 0.0, length(normalize(center) - d));
          let twinkle = 0.65 + 0.35 * sin(scene.eye.w * (1.2 + star * 3.0) + star * 90.0) * scene.look.w;
          color += vec3f(0.85, 0.9, 1.0) * glow * twinkle * scene.look.x * smoothstep(0.03, 0.25, d.y) * (star - 0.9965) * 18.0;
        }
      }
      let moonMu = dot(d, scene.moonDir.xyz);
      let moon = smoothstep(0.99975, 0.99988, moonMu);
      let crater = 0.82 + 0.18 * noise2(d.xy * 900.0);
      color += vec3f(0.95, 0.95, 0.88) * moon * crater * 5.0 * scene.moonDir.w;
      color += vec3f(0.35, 0.45, 0.7) * pow(max(moonMu, 0.0), 300.0) * 0.12 * scene.moonDir.w;
      return color;
    }
    
    // Land toward the horizon, in three layers that each get less haze: far hills,
    // a nearer wooded ridge, then a hedgerow at the far edge of the meadow.
    fn farHills(azimuth: f32) -> f32 {
      // Rolling hills with a few higher shoulders, not a plateau.
      let swell = noise2(vec2f(azimuth * 2.1, 3.0));
      return 0.02 + 0.034 * swell * swell + 0.016 * noise2(vec2f(azimuth * 5.3, 9.0)) + 0.006 * noise2(vec2f(azimuth * 13.0, 4.0));
    }
    
    fn woodedRidge(azimuth: f32, detail: bool) -> f32 {
      let body = 0.004 + 0.02 * smoothstep(0.25, 0.85, noise2(vec2f(azimuth * 3.6, 1.5)));
      let crowns = 0.006 * noise2(vec2f(azimuth * 48.0, 6.0)) + 0.004 * noise2(vec2f(azimuth * 110.0, 2.0));
      return body + select(0.004, crowns, detail);
    }
    
    fn hedgerow(azimuth: f32) -> f32 {
      let crowns = noise2(vec2f(azimuth * 24.0, 7.0)) * 0.6 + noise2(vec2f(azimuth * 61.0, 2.0)) * 0.4;
      return (0.004 + 0.012 * smoothstep(0.3, 0.8, crowns)) * smoothstep(0.25, 0.55, noise2(vec2f(azimuth * 3.3, 4.0)));
    }
    
    /** Wild flowers dotted in the grass, out of focus: soft round discs that grow and fade with distance. */
    fn flowers(d: vec3f, p: vec2f, t: f32, base: vec3f, weight: f32) -> vec3f {
      if (t > 34.0 || weight < 0.01) { return base; }
      let size = 0.5;
      let cell = floor(p / size);
      // Circle of confusion, as a radius in metres at the flower's distance.
      let blur = APERTURE * abs(1.0 / FOCUS - 1.0 / t) * t;
      // Flowers fade with distance and into the dark at night; they grow in drifts, dense in a few patches.
      let visible = (1.0 - smoothstep(16.0, 34.0, t)) * (1.0 - 0.85 * scene.horizon.w) * weight;
      if (visible < 0.01) { return base; }
      let drift = 0.12 + 0.88 * smoothstep(0.42, 0.72, fbm2(p * 0.09 + vec2f(9.0, 2.0)));
      let light = scene.sunColor.rgb * scene.sunDir.w * 0.22 * (0.4 + 0.6 * smoothstep(-0.1, 0.3, scene.sunDir.y)) + ambient(vec3f(0.0, 1.0, 0.0)) * 0.7;
      var color = base;
      for (var j = -1; j <= 1; j++) {
        for (var i = -1; i <= 1; i++) {
          let c = cell + vec2f(f32(i), f32(j));
          let kind = hash21(c + 71.0);
          if (kind < 1.0 - 0.45 * drift) { continue; }
          let center = (c + 0.2 + 0.6 * hash22(c + 13.0)) * size;
          let offset = vec3f(center.x - p.x, 0.0, center.y - p.y);
          // Distance across the line of sight, so a defocused flower stays round on screen.
          let across = length(offset - d * dot(offset, d));
          let r = 0.014 + blur;
          let disc = smoothstep(r, r * 0.7, across) * clamp(pow(0.024 / r, 1.3), 0.08, 1.0);
          // Mostly daisies and buttercups, now and then a poppy or a clover head.
          let pick = hash21(c + 5.0);
          var petals = vec3f(0.82, 0.8, 0.72);
          if (pick > 0.94) { petals = vec3f(0.72, 0.09, 0.05); } else if (pick > 0.66) { petals = vec3f(0.9, 0.7, 0.14); } else if (pick > 0.58) { petals = vec3f(0.55, 0.42, 0.66); }
          color = mix(color, petals * light, disc * visible * 0.75);
        }
      }
      return color;
    }
    
    /** The meadow: blades traced near the viewer, the canopy as a surface farther out, haze with distance. */
    fn meadow(d: vec3f, detail: bool) -> vec3f {
      // Distant fields take a dim haze, half the grass's own colour: never a bright band under the hills.
      let haze = mix(scene.ground.rgb * 1.25, horizonTint(d) * 0.3, 0.5);
      let dy = min(d.y, -1e-4);
      let t = (GROUND - scene.eye.y) / dy;
      let p = scene.eye.xz + d.xz * t;
      let gl = grassLighting(d);
      var color = canopyColor(d, p, gl, t);
      let fog = 1.0 - exp(-t * (0.006 + 0.01 * scene.ground.w));
      if (!detail) { return mix(color, haze, fog); }
      let tTop = (GROUND + GRASS_HEIGHT - scene.eye.y) / dy;
      let handover = smoothstep(GRASS_REACH * 0.65, GRASS_REACH, tTop);
      if (handover < 1.0) {
        // Blades come from the half-resolution grass pass: their colour, and how much canopy shows through.
        let blades = textureSampleLevel(grassImage, grassSampler, pixelUV, 0.0);
        color = mix(blades.rgb + blades.a * color, color, handover);
      }
      color = flowers(d, p, t, color, handover);
      color += meadowGlints(d);
      // The lantern's pool of warm light on the grass around it.
      let pool = length(p - LANTERN.xz);
      color += vec3f(0.3, 0.27, 0.12) * FLAME * lanternLevel() * 0.15 / (1.0 + pool * pool * 1.2);
      return mix(color, haze, fog);
    }
    
    /** Out-of-focus canopy of the apple tree: dark leaf masses with bright bokeh holes. */
    fn canopy(d: vec3f, background: vec3f, detail: bool) -> vec3f {
      let angles = directionAngles(d);
      let center = vec2f(-1.12, 0.36);
      let offset = (angles - center) * vec2f(1.0, 1.35);
      // The wobble moves the edge by 0.175 at most: beyond, or below the crown, no foliage and no noise to pay for.
      if (length(offset) > 0.8 || angles.y < -0.14) { return background; }
      let wobble = fbm2(angles * 3.2 + vec2f(1.7, 0.2)) - 0.5;
      let reach = length(offset) + wobble * 0.35;
      let mass = smoothstep(0.62, 0.42, reach) * smoothstep(-0.12, 0.08, angles.y + 0.02);
      if (mass < 0.001) { return background; }
      let backlight = pow(max(dot(d, scene.sunDir.xyz), 0.0), 3.0) * scene.sunDir.w;
      let foliage = scene.canopy.rgb * (0.7 + 0.6 * noise2(angles * 9.0)) + scene.sunColor.rgb * vec3f(0.35, 0.55, 0.12) * 0.1 * backlight;
      var color = mix(background, foliage, mass * 0.94);
      if (!detail) { return color; }
      // Bokeh: one disc per cell, where the sky shows between the leaves.
      let size = 0.05;
      let sway = scene.canopy.w * 0.004 * sin(scene.wind.w * 2.3) * scene.look.w;
      let uv = (angles + vec2f(sway, 0.0)) / size;
      let cell = floor(uv);
      let rnd = hash22(cell);
      let radius = 0.08 + 0.2 * hash21(cell + 11.0) * hash21(cell + 17.0) + 0.06 * hash21(cell + 23.0);
      let disc = cell + radius + rnd * (1.0 - 2.0 * radius);
      let dist = length(uv - disc);
      let open = smoothstep(0.6, 0.9, hash21(cell * 1.7 + 3.0) + (0.62 - reach) * 0.6);
      if (open > 0.0) {
        let edge = smoothstep(radius, radius * 0.86, dist);
        let rim = 1.0 + 0.35 * smoothstep(radius * 0.55, radius * 0.95, dist);
        let light = (sky(d, false) * 0.55 + scene.sunColor.rgb * vec3f(1.0, 0.85, 0.6) * (0.06 + 0.4 * backlight)) * (0.55 + 0.45 * hash21(cell + 31.0));
        color = mix(color, light * rim, edge * open * mass);
      }
      return color;
    }
    
    /** Sun glints on the far meadow grass, blurred into small warm discs. */
    fn meadowGlints(d: vec3f) -> vec3f {
      let angles = directionAngles(d);
      let size = vec2f(0.022, 0.012);
      let uv = angles / size;
      let cell = floor(uv);
      let rnd = hash22(cell);
      let dist = length((uv - cell - 0.2 - rnd * 0.6) * vec2f(1.0, 1.8));
      let glint = smoothstep(0.16, 0.1, dist) * step(0.93, hash21(cell + 5.0));
      let facing = pow(max(dot(normalize(vec3f(d.x, 0.0, d.z)), normalize(vec3f(scene.sunDir.x, 0.0, scene.sunDir.z))), 0.0), 6.0) * (1.0 - smoothstep(0.1, 0.5, scene.sunDir.y));
      let shimmer = 0.6 + 0.4 * sin(scene.wind.w * (2.0 + rnd.x * 3.0) + rnd.y * 30.0) * scene.look.w;
      return scene.sunColor.rgb * glint * facing * shimmer * 0.12 * scene.sunDir.w * smoothstep(-0.02, -0.12, d.y) * smoothstep(-0.4, -0.18, d.y);
    }
    
    /** Everything far away: sky, hills, meadow and the tree's canopy, all out of focus. */
    fn garden(d: vec3f, withDiscs: bool) -> vec3f { return gardenAt(d, withDiscs, true); }
    
    /** Without `detail` (for reflections in brushed metal): no bokeh, no glints, no blades. */
    fn gardenAt(d: vec3f, withDiscs: bool, detail: bool) -> vec3f {
      let angles = directionAngles(d);
      let far = farHills(angles.x);
      let ridge = woodedRidge(angles.x, detail);
      let hedge = hedgerow(angles.x);
      var color: vec3f;
      if (d.y <= 0.0) {
        color = meadow(d, detail);
      } else if (d.y > max(far, max(ridge, hedge))) {
        color = sky(d, withDiscs);
      } else {
        let haze = horizonTint(d) * 0.5;
        // The farther the layer, the more it melts into the haze; soft edges read as out of focus.
        color = mix(scene.ground.rgb * vec3f(0.4, 0.48, 0.56), haze, scene.ground.w * (0.6 + 0.35 * clamp(d.y / far, 0.0, 1.0)));
        let wood = mix(scene.ground.rgb * vec3f(0.26, 0.34, 0.3) * (0.85 + 0.3 * noise2(vec2f(angles.x * 70.0, d.y * 400.0))), haze, scene.ground.w * 0.32);
        color = mix(color, wood, smoothstep(ridge + 0.0012, ridge - 0.0012, d.y));
        let hedgeColor = mix(scene.ground.rgb * vec3f(0.2, 0.28, 0.2), haze, 0.16 * scene.ground.w);
        color = mix(color, hedgeColor, smoothstep(hedge + 0.001, hedge - 0.001, d.y));
      }
      return canopy(d, color, detail);
    }
    
    // ---------------------------------------------------------------- lighting
    
    fn softShadow(p: vec3f, l: vec3f, skip: u32) -> f32 {
      var light = 1.0;
      for (var i = 0u; i < TUBES; i++) {
        if (i == skip) { continue; }
        let c = tubeCenter(i);
        let a = tubeAxis(i);
        let s = raySegment(p, l, c.xyz + a.xyz * c.w, c.xyz - a.xyz * c.w);
        if (s.y > 0.0) { light = min(light, smoothstep(0.0, 1.0, (s.x - a.w) / (0.02 * s.y + 0.004))); }
      }
      // The clapper and plate discs, as spheres of their radius.
      for (var k = 0u; k < 2u; k++) {
        let index = select(PLATE, CLAPPER, k == 1u);
        let c = bodies[index];
        let r = bodies[index + 1u].w;
        let oc = c.xyz - p;
        let t = dot(oc, l);
        if (t > 0.0) { light = min(light, smoothstep(0.0, 1.0, (length(oc - l * t) - r * 0.8) / (0.03 * t + 0.01))); }
      }
      return light;
    }
    
    fn tubeOcclusion(p: vec3f, n: vec3f, skip: u32) -> f32 {
      var occlusion = 1.0;
      for (var i = 0u; i < TUBES; i++) {
        if (i == skip) { continue; }
        let c = tubeCenter(i);
        let a = tubeAxis(i);
        let ta = c.xyz + a.xyz * c.w;
        let ba = -a.xyz * 2.0 * c.w;
        let pa = p - ta;
        let h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
        let dv = pa - h * ba;
        let l = max(length(dv), a.w);
        let o = 1.0 - max(0.0, dot(-dv / l, n)) * (a.w * a.w) / (l * l) * 1.6;
        occlusion *= clamp(o, 0.0, 1.0);
      }
      return occlusion;
    }
    
    
    fn ggx(n: vec3f, h: vec3f, roughness: f32) -> f32 {
      let a = roughness * roughness;
      let nh = max(dot(n, h), 0.0);
      let d = nh * nh * (a * a - 1.0) + 1.0;
      return a * a / (PI * d * d);
    }
    
    
    /** Specular and diffuse from the sun and the moon, shadowed by the tubes. */
    fn direct(p: vec3f, n: vec3f, v: vec3f, albedo: vec3f, f0: vec3f, roughness: f32, skip: u32) -> vec3f {
      let light = lights();
      var color = vec3f(0.0);
      for (var k = 0; k < 2; k++) {
        let l = select(light.sunDir, light.moonDir, k == 1);
        let radiance = select(light.sun, light.moon, k == 1);
        if (dot(radiance, vec3f(1.0)) < 1e-4) { continue; }
        let nl = dot(n, l);
        if (nl <= 0.0) { continue; }
        let shadow = softShadow(p + n * 0.002, l, skip);
        let h = normalize(l + v);
        let fresnel = f0 + (1.0 - f0) * pow(clamp(1.0 - dot(h, v), 0.0, 1.0), 5.0);
        let spec = ggx(n, h, roughness) * fresnel * 0.25 / max(dot(n, v) * nl, 0.05) * nl;
        color += radiance * shadow * (albedo * nl / PI * 3.0 + spec);
      }
      return color;
    }
    
    // ---------------------------------------------------------------- materials
    
    /** What a tube shows along a reflected ray: a neighbouring tube, or the garden. */
    fn reflection(p: vec3f, r: vec3f, self_: u32) -> vec3f {
      let other = traceChime(p, r, self_);
      if (other.material == M_TUBE || other.material == M_RIM) {
        let q = p + r * other.t;
        return gardenAt(reflect(r, other.n), false, false) * BRUSHED * tubeOcclusion(q, other.n, other.id) + OXIDE * ambient(other.n);
      }
      if (other.material == M_BORE) { return ambient(-r) * 0.05; }
      return gardenAt(r, false, false);
    }
    
    fn shadeTube(hit: Hit, p: vec3f, v: vec3f) -> vec3f {
      let axis = tubeAxis(hit.id).xyz;
      let n = hit.n;
      let occlusion = tubeOcclusion(p, n, hit.id);
      let nv = max(dot(n, v), 0.0);
      let fresnel = BRUSHED + (1.0 - BRUSHED) * pow(max(1.0 - nv, 0.0), 5.0);
      let r = normalize(reflect(-v, n));
      var color = reflection(p + n * 0.002, r, hit.id) * fresnel * (0.5 + 0.5 * occlusion);
      color += OXIDE * ambient(n) * occlusion;
      // Brushed along its length: anisotropic GGX, rough around the tube and smoother
      // along it, so a low sun leaves a soft glint at the height where it reflects.
      let bitangent = normalize(cross(n, axis) + vec3f(0.0, 1e-5, 0.0));
      let light = lights();
      for (var k = 0; k < 2; k++) {
        let l = select(light.sunDir, light.moonDir, k == 1);
        let radiance = select(light.sun, light.moon, k == 1);
        let nl = dot(n, l);
        if (nl <= 0.0 || dot(radiance, vec3f(1.0)) < 1e-4) { continue; }
        let shadow = softShadow(p + n * 0.002, l, hit.id);
        let h = normalize(l + v);
        let ht = dot(h, axis) / 0.24;
        let hb = dot(h, bitangent) / 0.46;
        let hn = dot(h, n);
        let dd = ht * ht + hb * hb + hn * hn;
        let distribution = 1.0 / (PI * 0.24 * 0.46 * dd * dd);
        color += radiance * shadow * (distribution * fresnel * 0.55 / (4.0 * max(nv, 0.2)) + OXIDE * nl / PI);
      }
      // Faint wear: fine vertical scratches catch a little more of the sky.
      let wear = noise2(vec2f(hit.local.x * 3.0, atan2(n.z, n.x) * 12.0)) * 0.05;
      return color * (0.97 + wear);
    }
    
    fn woodGrain(q: vec2f, tone: vec3f) -> vec3f {
      let rings = sin((q.x + noise2(q * vec2f(3.0, 40.0)) * 0.012) * 520.0) * 0.5 + 0.5;
      let fibres = noise2(q * vec2f(6.0, 180.0));
      return tone * (0.78 + 0.16 * rings + 0.14 * fibres);
    }
    
    fn shadeWood(hit: Hit, p: vec3f, v: vec3f) -> vec3f {
      var albedo: vec3f;
      if (hit.material == M_SAIL) {
        albedo = woodGrain(hit.local.xy * vec2f(1.0, 0.12) + vec2f(0.0, hit.local.x * 2.0), vec3f(0.44, 0.29, 0.16));
      } else {
        let tone = select(vec3f(0.3, 0.15, 0.07), vec3f(0.36, 0.2, 0.1), hit.material == M_CLAPPER);
        albedo = woodGrain(hit.local.xz, tone);
      }
      let occlusion = tubeOcclusion(p, hit.n, 999u);
      return direct(p, hit.n, v, albedo, vec3f(0.04), 0.55, 999u) + albedo * ambient(hit.n) * occlusion;
    }
    
    fn shadeBark(hit: Hit, v: vec3f) -> vec3f {
      let p = hit.local;
      let lichen = smoothstep(0.7, 0.82, noise3(p * 42.0 + 2.0)) * smoothstep(0.4, 0.7, noise3(p * 6.0));
      let ridges = noise3(p * vec3f(24.0, 110.0, 110.0));
      let bark = vec3f(0.13, 0.1, 0.085) * (0.55 + 0.6 * ridges);
      let albedo = mix(bark, vec3f(0.3, 0.33, 0.22), lichen * 0.6);
      let rim = pow(clamp(1.0 - dot(hit.n, v), 0.0, 1.0), 3.0) * 0.15;
      return direct(p, hit.n, v, albedo, vec3f(0.03), 0.8, 999u) + albedo * ambient(hit.n) + scene.sunColor.rgb * scene.sunDir.w * rim * albedo;
    }
    
    fn shadeLeaf(hit: Hit, p: vec3f, v: vec3f) -> vec3f {
      var n = hit.n;
      if (dot(n, v) < 0.0) { n = -n; }
      let vein = smoothstep(0.08, 0.0, abs(hit.local.y)) * 0.25 + smoothstep(0.05, 0.0, abs(fract(hit.local.x * 6.0 + abs(hit.local.y) * 2.0) - 0.5) - 0.44) * 0.08;
      let albedo = vec3f(0.08, 0.15, 0.035) * (1.0 + vein) * (0.8 + 0.4 * hash11(f32(hit.id) + 0.5));
      let light = lights();
      // Backlit leaves glow: light through the blade, tinted by chlorophyll.
      let through = max(dot(-n, light.sunDir), 0.0);
      let translucency = light.sun * vec3f(0.3, 0.5, 0.06) * through * 0.45;
      return direct(p, n, v, albedo, vec3f(0.035), 0.45, 999u) + albedo * ambient(n) + translucency;
    }
    
    fn shadeApple(hit: Hit, p: vec3f, v: vec3f) -> vec3f {
      let blush = smoothstep(-0.4, 0.8, dot(hit.n, normalize(vec3f(-0.6, 0.4, 0.7))) + (noise3(hit.local * 90.0) - 0.5) * 0.5);
      let albedo = mix(vec3f(0.45, 0.33, 0.05), vec3f(0.42, 0.04, 0.03), blush);
      return direct(p, hit.n, v, albedo, vec3f(0.05), 0.32, 999u) + albedo * ambient(hit.n);
    }
    
    /** Washi with the verse in sumi ink; lit from behind it glows, and the ink shows through as a shadow. */
    fn shadePaper(hit: Hit) -> vec3f {
      let written = hit.local.z > 0.0;
      let uv = vec2f(0.5 - 0.5 * hit.local.x, hit.local.y);
      // The ink's mip: how many texels one pixel spans down the strip.
      let texels = pixelFootprint * hit.t * f32(textureDimensions(inkImage).y) / bodies[TANZAKU_AT + 1u].w;
      let ink = textureSampleLevel(inkImage, inkSampler, uv, log2(max(texels, 1.0))).r;
      let fibres = noise2(uv * vec2f(22.0, 90.0)) * 0.08 + noise2(uv * vec2f(160.0, 40.0)) * 0.04;
      let paper = vec3f(0.86, 0.81, 0.7) * (0.93 + fibres);
      let albedo = paper * (1.0 - select(0.3, 0.92, written) * ink);
      let light = lights();
      var color = albedo * ambient(hit.n) * 0.9;
      for (var k = 0; k < 2; k++) {
        let l = select(light.sunDir, light.moonDir, k == 1);
        let radiance = select(light.sun, light.moon, k == 1);
        let nl = dot(hit.n, l);
        color += radiance * albedo * max(nl, 0.0) / PI * 3.0;
        color += radiance * paper * vec3f(1.0, 0.92, 0.75) * max(-nl, 0.0) * 0.45 * (1.0 - 0.85 * ink);
      }
      return color;
    }
    
    fn shade(hit: Hit, ro: vec3f, rd: vec3f) -> vec3f {
      let p = ro + rd * hit.t;
      let v = -rd;
      switch (hit.material) {
        case 1u, 2u: { return shadeTube(hit, p, v); }
        case 3u: { return ambient(hit.n) * 0.04 * vec3f(0.9, 0.92, 0.95); }
        case 4u, 5u, 6u: { return shadeWood(hit, p, v); }
        case 7u: { return shadeBark(hit, v); }
        case 8u: { return shadeLeaf(hit, p, v); }
        case 9u: { return shadeApple(hit, p, v); }
        case 10u: { return direct(p, hit.n, v, vec3f(0.3, 0.2, 0.06), vec3f(0.95, 0.75, 0.4), 0.3, 999u) + garden(reflect(rd, hit.n), false) * vec3f(0.6, 0.45, 0.2); }
        case 11u: { return shadePaper(hit); }
        default: { return vec3f(0.0); }
      }
    }
    
    // ---------------------------------------------------------------- cords and fireflies
    
    /** Waxed cords as antialiased lines: coverage from their closest approach to the ray. */
    fn cords(ro: vec3f, rd: vec3f, tMax: f32, footprint: f32, base: vec3f) -> vec3f {
      var color = base;
      let light = lights();
      for (var i = 0u; i < STRINGS; i++) {
        let a = bodies[STRING_BASE + 2u * i];
        let b = bodies[STRING_BASE + 2u * i + 1u];
        let s = raySegment(ro, rd, a.xyz, b.xyz);
        if (s.y <= 0.0 || s.y >= tMax) { continue; }
        let pixel = footprint * s.y;
        let radius = max(a.w, pixel * 0.5);
        let coverage = clamp((radius - s.x) / pixel + 0.5, 0.0, 1.0) * min(1.0, a.w / radius * 1.3);
        if (coverage <= 0.0) { continue; }
        let axis = normalize(b.xyz - a.xyz);
        // A thin cylinder lit from the sun: diffuse around it, and a bright edge against the light.
        let across = sqrt(max(1.0 - dot(axis, light.sunDir) * dot(axis, light.sunDir), 0.0));
        let rim = pow(max(dot(rd, light.sunDir), 0.0), 8.0);
        let cord = vec3f(0.62, 0.55, 0.43) * (light.sun * (across * 0.22 + rim * 0.5) + ambient(vec3f(0.0, 1.0, 0.0)) * 0.9 + light.moon * across * 0.3);
        color = mix(color, cord, coverage);
      }
      return color;
    }
    
    const FIREFLIES: u32 = 18u;
    
    fn fireflies(ro: vec3f, rd: vec3f, tMax: f32, footprint: f32) -> vec3f {
      if (scene.look.y < 0.002) { return vec3f(0.0); }
      var glow = vec3f(0.0);
      let clock = scene.eye.w;
      for (var i = 0u; i < FIREFLIES; i++) {
        let seed = f32(i) * 1.618 + 0.37;
        let home = vec3f(hash11(seed) * 4.4 - 2.6, hash11(seed + 1.0) * 1.7 - 1.35, hash11(seed + 2.0) * 3.4 - 2.4);
        let wander = vec3f(sin(clock * (0.23 + hash11(seed + 3.0) * 0.2) + seed * 7.0), sin(clock * (0.17 + hash11(seed + 4.0) * 0.15) + seed * 3.0) * 0.5, cos(clock * (0.19 + hash11(seed + 5.0) * 0.2) + seed * 5.0)) * 0.35;
        let p = home + wander;
        let oc = p - ro;
        let t = dot(oc, rd);
        if (t <= 0.0 || t >= tMax) { continue; }
        let d = length(oc - rd * t);
        // Out of the focal plane a firefly spreads into a soft disc.
        let blur = 0.004 + abs(t - 3.1) * 0.012;
        let size = max(blur, footprint * t);
        let pulse = pow(max(sin(clock * (0.9 + hash11(seed + 6.0) * 0.8) + seed * 11.0), 0.0), 6.0);
        let energy = 0.00006 / (size * size);
        glow += vec3f(1.0, 0.86, 0.32) * exp(-d * d / (size * size)) * energy * pulse * 18.0;
      }
      return glow * scene.look.y;
    }
    
    // ---------------------------------------------------------------- leaves and petals on the wind
    
    /** A leaf torn from the branch (some yellowing), or a daisy, buttercup or poppy petal lifted from the meadow. */
    fn motePaint(kind: f32, seed: f32) -> vec3f {
      if (kind < 0.5) { return mix(vec3f(0.1, 0.2, 0.035), vec3f(0.34, 0.27, 0.06), seed * seed); }
      if (kind < 1.5) { return vec3f(0.85, 0.83, 0.76); }
      if (kind < 2.5) { return vec3f(0.9, 0.68, 0.1); }
      return vec3f(0.75, 0.08, 0.04);
    }
    
    /** Each mote a small ellipse turning as it tumbles, thinning edge on, and blurred by the lens with its distance. */
    fn drift(ro: vec3f, rd: vec3f, tMax: f32, base: vec3f) -> vec3f {
      let count = min(u32(bodies[DRIFT_AT].x), DRIFT_MAX);
      if (count == 0u) { return base; }
      var color = base;
      let light = lights();
      let side = normalize(cross(rd, vec3f(0.0, 1.0, 0.0)) + vec3f(1e-5, 0.0, 0.0));
      let rise = cross(side, rd);
      let back = pow(clamp(dot(rd, light.sunDir), 0.0, 1.0), 4.0);
      for (var i = 0u; i < count; i++) {
        let a = bodies[DRIFT_AT + 1u + 2u * i];
        let b = bodies[DRIFT_AT + 2u + 2u * i];
        let oc = a.xyz - ro;
        let t = dot(oc, rd);
        if (t <= 0.0 || t >= tMax) { continue; }
        let q = oc - rd * t;
        let blur = circleOfConfusion(t);
        if (dot(q, q) > (a.w + 1.5 * blur) * (a.w + 1.5 * blur)) { continue; }
        let turn = b.w * 6.2831 + b.x * 0.3;
        let e1 = side * cos(turn) + rise * sin(turn);
        let e2 = cross(rd, e1);
        let leaf = b.y < 0.5;
        let face = abs(sin(b.x));
        let long = a.w;
        let wide = a.w * select(0.8, 0.4, leaf) * (0.15 + 0.85 * abs(cos(b.x)));
        let r = length(vec2f(dot(q, e1) / long, dot(q, e2) / wide));
        let alpha = clamp(0.5 - (r - 1.0) * wide / (2.0 * blur), 0.0, 1.0) * (long * wide) / ((long + blur) * (wide + blur)) * b.z;
        if (alpha < 0.003) { continue; }
        let paint = motePaint(b.y, b.w);
        var shade = paint * (light.sun * (0.2 + 0.5 * face) + ambient(vec3f(0.0, 1.0, 0.0)) + light.moon * 0.4);
        // Backlit it glows; a waxy leaf flashes as it turns to the sun.
        shade += light.sun * paint * back * 1.6 + light.sun * pow(face, 12.0) * select(0.02, 0.12, leaf) * (1.0 - back);
        color = mix(color, shade, alpha);
      }
      return color;
    }
    
    // ---------------------------------------------------------------- one ray
    
    /**
     * Whether anything solid passes within two pixels of this ray: only there do extra rays sharpen an edge.
     * Everything else (the garden, the analytically antialiased cords, soft fireflies) looks the same with one.
     */
    fn nearSolid(ro: vec3f, rd: vec3f, footprint: f32) -> bool {
      let spread = footprint * 2.0;
      for (var i = 0u; i < TUBES; i++) {
        let c = tubeCenter(i);
        let a = tubeAxis(i);
        let s = raySegment(ro, rd, c.xyz + a.xyz * c.w, c.xyz - a.xyz * c.w);
        if (s.x < a.w + spread * s.y) { return true; }
      }
      // Wooden parts and the brass ring, within their bounding spheres.
      for (var k = 0u; k < 4u; k++) {
        var center = vec3f(0.0, BRIDLE, 0.0);
        var radius = 0.012;
        if (k < 2u) {
          let index = select(PLATE, CLAPPER, k == 1u);
          center = bodies[index].xyz;
          radius = bodies[index + 1u].w + bodies[index].w;
        } else if (k == 2u) {
          center = bodies[SAIL].xyz;
          radius = length(vec3f(bodies[SAIL + 1u].w, bodies[SAIL + 2u].w, bodies[SAIL].w));
        }
        let oc = center - ro;
        let t = max(dot(oc, rd), 0.0);
        if (length(oc - rd * t) < radius + spread * t) { return true; }
      }
      // The tanzaku, within the sphere around its middle.
      let strip = bodies[TANZAKU_AT];
      let stripAlong = bodies[TANZAKU_AT + 1u];
      let middle = strip.xyz + stripAlong.xyz * stripAlong.w * 0.5 - ro;
      let tm = max(dot(middle, rd), 0.0);
      if (length(middle - rd * tm) < stripAlong.w * 0.5 + strip.w + spread * tm) { return true; }
      // The branch within its padded capsules, which also bound the bark's relief.
      for (var i = 0u; i < BRANCH_SEGMENTS; i++) {
        let a = BRANCH[2u * i];
        let b = BRANCH[2u * i + 1u];
        let s = raySegment(ro, rd, a.xyz, b.xyz);
        if (s.x < max(a.w, b.w) + 0.03 + spread * s.y) { return true; }
      }
      // Leaves flutter around their base, never farther than their length; apples hang still.
      for (var i = 0u; i < LEAF_COUNT; i++) {
        let oc = LEAVES[i].xyz - ro;
        let t = max(dot(oc, rd), 0.0);
        if (length(oc - rd * t) < 0.13 + spread * t) { return true; }
      }
      for (var i = 0u; i < 3u; i++) {
        let oc = APPLES[i].xyz - ro;
        let t = max(dot(oc, rd), 0.0);
        if (length(oc - rd * t) < APPLES[i].w + spread * t) { return true; }
      }
      return false;
    }
    
    /** The garden behind the pixel, computed at most once and only when a ray misses the chime. */
    var<private> pixelCenter: vec3f;
    var<private> pixelUV: vec2f;
    var<private> gardenReady: bool = false;
    var<private> gardenCache: vec3f;
    fn pixelGarden() -> vec3f {
      if (!gardenReady) {
        let stone = textureSampleLevel(lanternImage, grassSampler, pixelUV, 0.0);
        gardenCache = garden(pixelCenter, true) * (1.0 - stone.a) + stone.rgb;
        gardenReady = true;
      }
      return gardenCache;
    }
    
    fn trace(ro: vec3f, rd: vec3f, footprint: f32) -> vec3f {
      var hit = traceChime(ro, rd, 999u);
      let branch = traceBranch(ro, rd, hit.t);
      if (branch.t < hit.t) { hit = branch; }
      let leaf = traceLeaves(ro, rd, hit.t);
      if (leaf.t < hit.t) { hit = leaf; }
      let apple = traceApple(ro, rd, hit.t);
      if (apple.t < hit.t) { hit = apple; }
      var color: vec3f;
      if (hit.material == M_NONE) { color = pixelGarden(); } else { color = shade(hit, ro, rd); }
      color = cords(ro, rd, hit.t, footprint, color);
      color = drift(ro, rd, hit.t, color);
      return color + fireflies(ro, rd, hit.t, footprint);
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let ro = scene.eye.xyz;
      let texel = 1.0 / scene.viewport.xy;
      let center = rayDirection(uv);
      let footprint = length(rayDirection(uv + vec2f(texel.x, 0.0)) - center);
      pixelFootprint = footprint;
      pixelCenter = center;
      pixelUV = uv;
      let samples = u32(scene.viewport.w);
      var color = vec3f(0.0);
      if (samples <= 1u || !nearSolid(ro, center, footprint)) {
        color = trace(ro, center, footprint);
      } else {
        // Rotated-grid supersampling for the thin metal edges, the bark and the leaves.
        var offsets = array<vec2f, 4>(vec2f(0.125, 0.375), vec2f(0.375, -0.125), vec2f(-0.125, -0.375), vec2f(-0.375, 0.125));
        for (var s = 0u; s < 4u; s++) {
          color += trace(ro, rayDirection(uv + offsets[s] * texel), footprint);
        }
        color *= 0.25;
      }
      let near = textureSampleLevel(foregroundImage, grassSampler, uv, 0.0);
      color = color * near.a + near.rgb;
      // GPU compilers may fold `x == x` away, so NaN guards cannot live here: every pow() base is clamped.
      return vec4f(clamp(color, vec3f(0.0), vec3f(200.0)), 1.0);
    }
  • present.wgslfragment Final image: exposure, bloom, ACES filmic curve, a soft vignette, then sRGB with a little noise so the dusk gradients never band on 8-bit screens. 35 lignes
    Fichier
    src/carillon/present.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    3 liaisons
    // Final image: exposure, bloom, ACES filmic curve, a soft vignette, then sRGB
    // with a little noise so the dusk gradients never band on 8-bit screens.
    
    @group(0) @binding(1) var hdr: texture_2d<f32>;
    @group(0) @binding(2) var bloom: texture_2d<f32>;
    @group(0) @binding(3) var clampSampler: sampler;
    
    fn aces(x: vec3f) -> vec3f {
      return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3f(0.0), vec3f(1.0));
    }
    
    fn toSrgb(c: vec3f) -> vec3f {
      return select(1.055 * pow(c, vec3f(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3f(0.0031308));
    }
    
    fn grain(pixel: vec2f, frame: f32) -> f32 {
      return fract(52.9829189 * fract(dot(pixel + frame * vec2f(5.588238, 5.588238), vec2f(0.06711056, 0.00583715))));
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let image = textureSampleLevel(hdr, clampSampler, uv, 0.0).rgb;
      let glow = textureSampleLevel(bloom, clampSampler, uv, 0.0).rgb;
      var color = (image + glow * scene.look.z) * scene.zenith.w;
      let centered = uv - 0.5;
      color *= 1.0 - 0.3 * smoothstep(0.2, 0.9, dot(centered, centered) * 2.0);
      // Grade: a little more colour, then a gentle S-curve in display space so the haze never goes milky.
      var mapped = aces(color);
      let luma = dot(mapped, vec3f(0.2126, 0.7152, 0.0722));
      mapped = max(mix(vec3f(luma), mapped, 1.12), vec3f(0.0));
      var display = toSrgb(clamp(mapped, vec3f(0.0), vec3f(1.0)));
      display = mix(display, display * display * (3.0 - 2.0 * display), 0.32);
      color = display;
      let noise = (grain(position.xy, fract(scene.eye.w * 7.0) * 64.0) + grain(position.yx + 17.0, 3.0) - 1.0) / 255.0;
      return vec4f(color + noise * 1.5, 1.0);
    }
  • bloom.wgslPartagé, présenté avec Ce qui flotte

À tout vent

WebGPU · WGSL · vgpu

6 shaders · 1 026 lignes · 1 partagé

Ouvrir la scène
  • common.wgslmodule Shared by the /pissenlit shaders. The TypeScript side prepends the clock's dimensions (RECEPTACLE, ACHENE, BEAK, PAPPUS, HUB, CLOCK_R, PRIMS, FIBERS). 182 lignes
    Fichier
    src/pissenlit/common.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    1 liaison
    // Shared by the /pissenlit shaders. The TypeScript side prepends the clock's
    // dimensions (RECEPTACLE, ACHENE, BEAK, PAPPUS, HUB, CLOCK_R, PRIMS, FIBERS).
    
    struct Scene {
      view: mat4x4f,
      proj: mat4x4f,
      invViewProj: mat4x4f,
      eye: vec4f,       // camera position, w: scene clock (s)
      sunDir: vec4f,    // toward the sun, w: how much of the disc clears the hedgerow
      sun: vec4f,       // sun radiance (linear HDR), w: mist (0..1)
      zenith: vec4f,    // sky overhead, w: exposure
      horizon: vec4f,   // sky at the horizon, w: dandelions open across the meadow (0..1)
      glow: vec4f,      // the horizon toward the sun, w: humidity (dew)
      ground: vec4f,    // light bounced by the meadow, w: bloom strength
      meadow: vec4f,    // the far meadow's colour, w: dust in the light (0..1)
      lens: vec4f,      // focus distance (m), aperture (CoC px per dioptre), focal length (px), lens shift
      viewport: vec4f,  // width, height (px), pixel ratio, motion allowed (0/1)
      clock: vec4f,     // the head's centre, w: share of seeds still on it
      wind: vec4f,      // distance the air has travelled (m), w: wind speed at the head (m/s)
      gust: vec4f,      // mean wind direction at the head (unit), w: gust (0..1)
    };
    @group(0) @binding(0) var<uniform> scene: Scene;
    
    const PI: f32 = 3.14159265;
    const TAU: f32 = 6.28318531;
    /** Height of the lens above the meadow is the camera's; the ground is the plane y = 0. */
    
    fn hash11(p: f32) -> f32 { return fract(sin(p * 127.1) * 43758.5453); }
    fn hash21(p: vec2f) -> f32 {
      var q = fract(p * vec2f(0.1031, 0.1030));
      q += dot(q, q.yx + 33.33);
      return fract((q.x + q.y) * q.x);
    }
    fn hash22(p: vec2f) -> vec2f {
      var q = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973));
      q += dot(q, q.yzx + 33.33);
      return fract((q.xx + q.yz) * q.zy);
    }
    fn hash31(p: vec3f) -> f32 {
      var q = fract(p * 0.1031);
      q += dot(q, q.zyx + 31.32);
      return fract((q.x + q.y) * q.z);
    }
    fn hash33(p: vec3f) -> vec3f {
      var q = fract(p * vec3f(0.1031, 0.1030, 0.0973));
      q += dot(q, q.yxz + 33.33);
      return fract((q.xxy + q.yxx) * q.zyx);
    }
    fn noise1(x: f32) -> f32 {
      let i = floor(x);
      let f = fract(x);
      return mix(hash11(i), hash11(i + 1.0), f * f * (3.0 - 2.0 * f));
    }
    fn noise2(p: vec2f) -> f32 {
      let i = floor(p);
      let f = fract(p);
      let u = f * f * (3.0 - 2.0 * f);
      return mix(mix(hash21(i), hash21(i + vec2f(1.0, 0.0)), u.x), mix(hash21(i + vec2f(0.0, 1.0)), hash21(i + vec2f(1.0, 1.0)), u.x), u.y);
    }
    fn fbm2(p: vec2f, octaves: i32) -> f32 {
      var sum = 0.0;
      var amplitude = 0.5;
      var q = p;
      for (var i = 0; i < octaves; i++) {
        sum += amplitude * noise2(q);
        q = q * 2.03 + vec2f(17.1, 3.7);
        amplitude *= 0.5;
      }
      return sum;
    }
    
    fn sq(x: f32) -> f32 { return x * x; }
    fn luminance(c: vec3f) -> f32 { return dot(c, vec3f(0.2126, 0.7152, 0.0722)); }
    
    /** Camera basis, from the view matrix's rows. */
    fn camRight() -> vec3f { return vec3f(scene.view[0][0], scene.view[1][0], scene.view[2][0]); }
    fn camUp() -> vec3f { return vec3f(scene.view[0][1], scene.view[1][1], scene.view[2][1]); }
    fn camForward() -> vec3f { return -vec3f(scene.view[0][2], scene.view[1][2], scene.view[2][2]); }
    
    /** Defocus blur radius (px) at view depth d. */
    fn blurAt(depth: f32) -> f32 {
      return 0.5 * scene.lens.y * abs(1.0 / max(depth, 1e-3) - 1.0 / scene.lens.x);
    }
    
    /** Henyey–Greenstein phase function. */
    fn phaseHG(cosTheta: f32, g: f32) -> f32 {
      let g2 = g * g;
      return (1.0 - g2) / (4.0 * PI * pow(max(1.0 + g2 - 2.0 * g * cosTheta, 1e-4), 1.5));
    }
    
    /** The sky in a direction (no sun disc): dawn glows along the horizon toward the sun. */
    fn skyColor(dir: vec3f) -> vec3f {
      let up = max(dir.y, 0.0);
      var color = mix(scene.horizon.rgb, scene.zenith.rgb, pow(up, 0.45));
      let sun = scene.sunDir.xyz;
      let flat = normalize(vec3f(dir.x, 0.0, dir.z) + vec3f(1e-5, 0.0, 0.0));
      let sunFlat = normalize(vec3f(sun.x, 0.0, sun.z) + vec3f(1e-5, 0.0, 0.0));
      let toward = max(dot(flat, sunFlat), 0.0);
      color += scene.glow.rgb * pow(toward, 3.0) * exp(-up * 7.0) * 0.65;
      // Forward scattering around the sun: a warm aureole.
      let aureole = phaseHG(dot(dir, sun), 0.86) * scene.sun.rgb * scene.sunDir.w * 0.06;
      color += aureole * (0.6 + 0.4 * exp(-up * 3.0));
      // Morning mist whitens the low sky.
      color = mix(color, mix(scene.horizon.rgb, scene.glow.rgb, pow(toward, 2.0) * 0.6), scene.sun.w * exp(-up * 14.0) * 0.5);
      return color;
    }
    
    /** Light arriving from a direction, for reflections: sky above, sunlit meadow below. */
    fn environment(dir: vec3f) -> vec3f {
      if (dir.y >= 0.0) { return skyColor(dir); }
      let lit = scene.meadow.rgb * (0.4 + 0.6 * scene.sunDir.w) + scene.ground.rgb * 0.5;
      return mix(lit, scene.horizon.rgb * 0.8, exp(dir.y * 12.0) * 0.6);
    }
    
    /** Hemispherical sky and meadow light on a surface facing n. */
    fn ambient(n: vec3f) -> vec3f {
      let skyLight = mix(scene.horizon.rgb, scene.zenith.rgb, 0.45) * 0.9;
      return mix(scene.ground.rgb * 1.4, skyLight, clamp(n.y * 0.5 + 0.5, 0.0, 1.0));
    }
    
    /**
     * Sunlight left after crossing the clock toward the sun: the pappi scatter a
     * part of it, and the dense core of achenes around the receptacle stops most.
     */
    fn clockShadow(p: vec3f) -> f32 {
      let density = scene.clock.w;
      if (density <= 0.001) { return 1.0; }
      let l = scene.sunDir.xyz;
      let o = p - scene.clock.xyz;
      let b = dot(o, l);
      let c = dot(o, o) - CLOCK_R * CLOCK_R;
      let disc = b * b - c;
      if (disc <= 0.0) { return 1.0; }
      let s = sqrt(disc);
      let t1 = -b + s;
      if (t1 <= 0.0) { return 1.0; }
      let t0 = max(-b - s, 0.0);
      let path = t1 - t0;
      // Closest approach of the sun ray to the centre, if the centre lies sunward.
      let nearest = select(length(o), length(o - l * b), b < 0.0);
      let core = 1.0 - 0.75 * density * (1.0 - smoothstep(RECEPTACLE + ACHENE * 0.6, RECEPTACLE + ACHENE * 1.6, nearest));
      return exp(-24.0 * density * path) * core;
    }
    
    /**
     * A pappus filament lit by the sun, after the hair models (Marschner et al.
     * 2003): light leaves a fibre on a cone around it. Reflection is weak and broad;
     * a thin translucent fibre shines mostly by forward transmission, so a backlit
     * clock blazes along its edge.
     */
    fn fiberLight(t: vec3f, v: vec3f, p: vec3f, wet: f32) -> vec3f {
      let l = scene.sunDir.xyz;
      let sinI = dot(l, t);
      let sinR = dot(v, t);
      let lp = l - t * sinI;
      let vp = v - t * sinR;
      let cosPhi = dot(lp, vp) * inverseSqrt(max(dot(lp, lp) * dot(vp, vp), 1e-8));
      let cone = sinI + sinR;
      let mR = exp(-sq(cone + 0.1) / 0.05);
      let mTT = exp(-sq(cone - 0.03) / 0.022);
      let nR = 0.5 * sqrt(max(0.5 + 0.5 * cosPhi, 0.0));
      let nTT = exp(-sq(cosPhi + 1.0) / 0.3);
      let cosI = sqrt(max(1.0 - sinI * sinI, 0.0));
      // Wet fibres clump and lose their sheen.
      let dry = 1.0 - 0.45 * wet;
      // Light scattered on through the hollow walls and by neighbouring hairs: a broad
      // forward glow, and the diffraction peak of a 16 µm fibre a few degrees around the sun.
      let behind = max(dot(-v, l), 0.0);
      let forward = (pow(behind, 5.0) + 2.5 * exp((behind - 1.0) / 0.012)) * cosI;
      let direct = scene.sun.rgb * clockShadow(p) * ((0.7 * mR * nR + 1.5 * mTT * nTT + 0.35 * forward) * dry + 0.04 * cosI);
      return direct + ambient(normalize(vec3f(0.0, 1.0, 0.0) - t * t.y + vec3f(0.0, 1e-3, 0.0))) * 0.45;
    }
    
    /** Diffuse, wrapped and backlit light on a plant surface. */
    fn plantLight(n: vec3f, v: vec3f, p: vec3f, albedo: vec3f, translucency: f32) -> vec3f {
      let l = scene.sunDir.xyz;
      let shadow = clockShadow(p);
      let diffuse = max(dot(n, l) * 0.8 + 0.2, 0.0);
      let through = pow(max(dot(-v, l), 0.0), 3.0) * translucency * (0.6 + 0.4 * max(-dot(n, l), 0.0));
      let rim = pow(1.0 - max(dot(n, v), 0.0), 3.0) * max(dot(-v, l), 0.0) * 0.6;
      return albedo * (scene.sun.rgb * shadow * (diffuse + through) + ambient(n)) + scene.sun.rgb * shadow * rim * albedo * 0.5;
    }
  • background.wgslfragment The meadow beyond a few metres, seen through a macro lens focused on the clock: sky, the sun, a hedgerow on the horizon, and the meadow reduced to soft discs of light (dandelions in flower, other clocks, dew glinting at sunrise). Rendered at half resolution: nothing here is sharp. 165 lignes
    Fichier
    src/pissenlit/background.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    0 liaison
    // The meadow beyond a few metres, seen through a macro lens focused on the
    // clock: sky, the sun, a hedgerow on the horizon, and the meadow reduced to soft
    // discs of light (dandelions in flower, other clocks, dew glinting at sunrise).
    // Rendered at half resolution: nothing here is sharp.
    
    const HEDGE: f32 = 90.0;
    const BANDS: i32 = 11;
    
    /** Angular blur radius (rad) of something at distance d. */
    fn blurAngle(d: f32) -> f32 {
      return max(blurAt(d) / scene.lens.z, 0.0012);
    }
    
    fn wrapAngle(a: f32) -> f32 { return a - TAU * floor((a + PI) / TAU); }
    
    /**
     * Height of the hedgerow's crown (m) along an azimuth: a low hedge where the sun
     * rises behind the clock, and a wood of tall oaks off to the left, where the wind
     * carries the seeds, so they shine against shade.
     */
    fn hedgeHeight(azimuth: f32) -> f32 {
      let broad = noise1(azimuth * 5.0 + 3.0);
      let crowns = noise1(azimuth * 23.0) * 0.6 + noise1(azimuth * 61.0) * 0.25;
      // The wood runs behind the clock, so its seeds shine against shade; its edge
      // steps down through smaller trees and scrub.
      let wood = smoothstep(-2.1, -1.6, azimuth) * smoothstep(0.42, 0.18, azimuth);
      let trees = max(smoothstep(0.5, 0.85, noise1(azimuth * 3.1 + 11.0)), wood);
      // Oak crowns: rounded bumps a few degrees wide.
      let oaks = sqrt(max(sin(azimuth * 26.0 + noise1(azimuth * 7.0) * 3.0), 0.0)) * 2.5 + noise1(azimuth * 40.0) * 1.5;
      return 2.0 + 1.2 * broad + crowns * (1.2 + 2.0 * trees) + trees * 3.0 + wood * (9.5 + oaks);
    }
    
    fn sunDisc(dir: vec3f) -> vec3f {
      // A defocused sun is a disc as wide as the lens's blur, as bright as the energy allows.
      let physical = 0.0047;
      let radius = max(blurAngle(1e4), physical);
      let angle = acos(clamp(dot(dir, scene.sunDir.xyz), -1.0, 1.0));
      let disc = 1.0 - smoothstep(radius * 0.86, radius, angle);
      let energy = sq(physical / radius);
      return scene.sun.rgb * scene.sunDir.w * (disc * 70.0 * energy + exp(-angle / (radius * 1.6)) * 0.9 * energy * 8.0);
    }
    
    /**
     * Soft discs of light along the meadow, band by band from 2 to 45 m. Beyond a
     * couple of metres every disc is as wide as the lens's blur; flowers tint the
     * meadow, while backlit clocks and dew are lights of their own and add up.
     */
    fn bokehBand(dir: vec3f, azimuth: f32, elevation: f32, base: vec3f) -> vec3f {
      var color = base;
      var light = vec3f(0.0);
      let eyeY = scene.eye.y;
      let sun = scene.sunDir.xyz;
      let facingSun = max(dot(normalize(vec3f(dir.x, 0.0, dir.z)), normalize(vec3f(sun.x, 0.0, sun.z) + vec3f(1e-5, 0.0, 0.0))), 0.0);
      let flowers = scene.horizon.w;
      let dew = scene.glow.w;
      let cosEl = cos(elevation);
      let sky = ambient(vec3f(0.0, 1.0, 0.0));
      for (var b = BANDS - 1; b >= 0; b--) {
        let d = 2.0 * pow(1.33, f32(b));
        let blur = blurAngle(d);
        let cell = blur * 1.6;
        let index = floor(azimuth / cell);
        // Mist swallows the far blobs at dawn.
        let haze = exp(-d * (0.01 + scene.sun.w * 0.06));
        for (var k = -1; k <= 1; k++) {
          let id = index + f32(k);
          let h = hash22(vec2f(id, f32(b) * 7.31 + 1.7));
          if (h.y > 0.42) { continue; }
          let kind = hash21(vec2f(id * 1.37, f32(b) + 19.1));
          let height = 0.03 + 0.24 * hash21(vec2f(id, f32(b) + 5.3));
          let blobAz = (id + 0.15 + 0.7 * h.x) * cell;
          let blobEl = atan2(height - eyeY, d * (0.85 + 0.3 * hash11(id + f32(b) * 3.0)));
          let offset = vec2f(wrapAngle(azimuth - blobAz) * cosEl, elevation - blobEl);
          let r = length(offset);
          if (r > blur) { continue; }
          let edge = 1.0 - smoothstep(blur * 0.8, blur, r);
          // Lens bokeh: a hair brighter at the rim.
          let rim = 1.0 + 0.25 * smoothstep(blur * 0.45, blur * 0.95, r);
          if (kind < 0.5) {
            // A dandelion in flower: seen against the sun it shows its green back, with gold at the rim.
            let gold = mix(vec3f(0.12, 0.16, 0.05), vec3f(0.95, 0.66, 0.08), flowers * (0.35 + 0.4 * hash11(id * 7.0)));
            let tint = gold * (scene.sun.rgb * 0.08 * (0.3 + facingSun) + sky * 1.1);
            // Shut at dawn, a flower head is small and green: it barely shows.
            let coverage = clamp(sq(0.02 / d / blur) * 1.6, 0.0, 0.55) * haze * (0.25 + 0.75 * flowers);
            color = mix(color, tint * rim, edge * coverage);
          } else if (kind < 0.7) {
            // Another clock, a lamp when the sun is behind it.
            let glow = scene.sun.rgb * (0.03 + 0.5 * pow(facingSun, 5.0)) + sky * 0.7;
            light += glow * rim * edge * clamp(sq(0.02 / d / blur) * 1.2, 0.0, 0.5) * haze;
          } else {
            // Dew on the grass, glinting toward a low sun.
            let glint = scene.sun.rgb * dew * (0.2 + 5.0 * pow(facingSun, 10.0)) + sky * dew * 0.3;
            light += glint * rim * edge * clamp(sq(0.012 / d / blur) * 1.4, 0.0, 0.45) * haze;
          }
        }
      }
      return color + light;
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let ndc = vec2f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
      let farPoint = scene.invViewProj * vec4f(ndc, 1.0, 1.0);
      let nearPoint = scene.invViewProj * vec4f(ndc, 0.0, 1.0);
      let dir = normalize(farPoint.xyz / farPoint.w - nearPoint.xyz / nearPoint.w);
      let azimuth = atan2(dir.x, -dir.z);
      let elevation = asin(clamp(dir.y, -1.0, 1.0));
      let eyeY = scene.eye.y;
      let mist = scene.sun.w;
    
      var color = skyColor(dir) + sunDisc(dir);
    
      // The hedgerow on the horizon, blurred as far things are, rim-lit when backlit.
      let hedgeTop = atan2(hedgeHeight(azimuth) - eyeY, HEDGE);
      let hedgeBase = atan2(-eyeY, HEDGE);
      let blurFar = blurAngle(HEDGE);
      let inHedge = 1.0 - smoothstep(hedgeTop - blurFar, hedgeTop + blurFar, elevation);
      let sunFlat = normalize(vec3f(scene.sunDir.x, 0.0, scene.sunDir.z) + vec3f(1e-5, 0.0, 0.0));
      let facing = max(dot(normalize(vec3f(dir.x, 0.0, dir.z)), sunFlat), 0.0);
      // Leaf masses: blotches of light and shade no finer than the lens's blur, lighter toward the crowns.
      let leaves = vec2f(azimuth, elevation) / max(blurFar, 1e-3);
      let masses = fbm2(leaves * 0.45 + vec2f(3.0, 1.0), 3);
      let crown = smoothstep(hedgeBase, hedgeTop, elevation);
      var foliage = vec3f(0.03, 0.052, 0.026) * (0.55 + 0.9 * masses) * (0.7 + 0.6 * crown)
        * (scene.sun.rgb * 0.1 + ambient(vec3f(0.0, 1.0, 0.0)) * 0.9);
      // Crowns toward the sun catch its light through their edges.
      foliage += scene.sun.rgb * (0.004 + 0.05 * pow(facing, 6.0)) * smoothstep(hedgeTop - blurFar * 4.0, hedgeTop, elevation) * masses;
      // Gaps between the leaves: the sky through them, as soft bright discs.
      let gapCell = floor(leaves / 3.2);
      let gap = hash22(gapCell + vec2f(41.0, 7.0));
      let gapCentre = (gapCell + 0.3 + 0.4 * gap) * 3.2;
      let gapDistance = length(leaves - gapCentre);
      let gapDisc = (1.0 - smoothstep(0.75, 1.15, gapDistance)) * (1.0 + 0.3 * smoothstep(0.4, 1.0, gapDistance));
      foliage = mix(foliage, skyColor(dir) * 0.85, gapDisc * step(0.86, hash21(gapCell + vec2f(3.0, 9.0))) * crown * 0.45);
      // Morning haze pales the far trees.
      let hedgeColor = mix(foliage, scene.horizon.rgb * 0.8, clamp(mist * 0.6 + 0.1, 0.0, 0.9));
      color = mix(color, hedgeColor, inHedge);
    
      // The meadow: the ground plane, with broad patches of grass and gold.
      if (elevation < hedgeBase + blurFar) {
        let down = max(-dir.y, 1e-4);
        let d = min(eyeY / down, HEDGE);
        let ground = scene.eye.xz + dir.xz * d;
        // Far away the ground is seen so obliquely that its texture would smear into streaks: let it fade.
        let far = smoothstep(35.0, 10.0, d);
        let broad = mix(0.5, fbm2(ground * 0.35, 3), far);
        let near = noise2(ground * 2.6) * smoothstep(12.0, 2.0, d);
        let gold = smoothstep(0.55, 0.85, noise2(ground * 0.18 + 7.0)) * scene.horizon.w * far;
        var grass = scene.meadow.rgb * (0.65 + 0.55 * broad + 0.25 * near);
        grass = mix(grass, vec3f(0.9, 0.62, 0.08) * luminance(scene.meadow.rgb) * 3.0, gold * 0.35);
        // Backlit grass tips glow toward the sun; dew whitens the meadow at dawn.
        grass += scene.sun.rgb * pow(facing, 5.0) * 0.012 * smoothstep(25.0, 3.0, d) * (1.0 + 1.5 * scene.glow.w);
        grass = mix(grass, scene.horizon.rgb * 0.45, scene.glow.w * 0.1 * smoothstep(1.0, 8.0, d));
        // Aerial perspective, and the ground-hugging mist of dawn.
        let haze = 1.0 - exp(-d * (0.008 + mist * 0.032));
        grass = mix(grass, mix(scene.horizon.rgb * 0.55, scene.glow.rgb * 0.45, facing * 0.5), haze);
        let meadowMask = smoothstep(hedgeBase + blurFar, hedgeBase - blurFar, elevation);
        color = mix(color, grass, meadowMask);
      }
    
      // The band where the meadow meets the hedge holds the bokeh.
      if (elevation < 0.08 && elevation > -0.35) {
        color = bokehBand(dir, azimuth, elevation, color);
      }
      return vec4f(color, 1.0);
    }
  • backdrop.wgslfragment The half-resolution meadow, stretched under the sharp foreground. 8 lignes
    Fichier
    src/pissenlit/backdrop.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    2 liaisons
    // The half-resolution meadow, stretched under the sharp foreground.
    
    @group(0) @binding(1) var backdrop: texture_2d<f32>;
    @group(0) @binding(2) var linearClamp: sampler;
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      return vec4f(textureSampleLevel(backdrop, linearClamp, uv, 0.0).rgb, 1.0);
    }
  • prims.wgslvertexfragment Everything near the lens as soft lines and discs, one slot per seed, stem, blade, flower or midge, sorted back to front on the CPU. Each slot draws only the primitives it needs; `starts` holds each slot's first one. Each primitive is widened by its circle of confusion and dimmed by the same factor, so a fibre ten microns wide stays exactly as bright in total whether it is in focus or smeared across twenty pixels. 589 lignes
    Fichier
    src/pissenlit/prims.wgsl
    Points d’entrée
    vs_prim vertex, fs_prim fragment
    Ressources
    2 liaisons
    // Everything near the lens as soft lines and discs, one slot per seed, stem,
    // blade, flower or midge, sorted back to front on the CPU. Each slot draws only
    // the primitives it needs; `starts` holds each slot's first one.
    // Each primitive is widened by its circle of confusion and dimmed by the same
    // factor, so a fibre ten microns wide stays exactly as bright in total whether
    // it is in focus or smeared across twenty pixels.
    
    @group(0) @binding(1) var<storage, read> slots: array<vec4f>;
    /** First primitive of each slot, then the total for every slot left unused (MAX_SLOTS + 1 entries). */
    @group(0) @binding(2) var<storage, read> starts: array<u32>;
    
    const KIND_SEED: u32 = 0u;
    const KIND_STEM: u32 = 1u;
    const KIND_BLADE: u32 = 2u;
    const KIND_FLOWER: u32 = 3u;
    const KIND_CLOCK: u32 = 4u;
    const KIND_MIDGE: u32 = 5u;
    
    const SHAPE_FIBER: f32 = 0.0;
    const SHAPE_SOLID: f32 = 1.0;
    const SHAPE_DISC: f32 = 2.0;
    const SHAPE_DROP: f32 = 3.0;
    const SHAPE_SPHERE: f32 = 4.0;
    const SHAPE_STREAK: f32 = 5.0;
    /** vec4s per slot (pack.ts SLOT_FLOATS / 4). */
    const SLOT_VEC4S: u32 = 5u;
    
    const NEAR: f32 = 0.005;
    const FIBER_RADIUS: f32 = 0.0000085;
    const BEAK_RADIUS: f32 = 0.00006;
    const STEM_SEGMENTS: u32 = 24u;
    const STEM_HAIRS: u32 = 48u;
    const BRACTS: u32 = 16u;
    /** Solid surfaces with their own detail (Piece.extra). */
    const MATERIAL_SCAPE: f32 = 1.0;
    const MATERIAL_BRACT: f32 = 2.0;
    const BLADE_SEGMENTS: u32 = 10u;
    const FLOWER_STEM: u32 = 8u;
    /** Blur beyond this many pixels costs fill and shows nothing new. */
    const MAX_BLUR: f32 = 110.0;
    
    struct Piece {
      shape: f32,
      p0: vec3f,
      p1: vec3f,
      r0: f32,
      r1: f32,
      color: vec3f,
      opacity: f32,
      translucency: f32,
      extra: f32,
    };
    
    struct Prim {
      @builtin(position) position: vec4f,
      @location(0) @interpolate(flat) ends: vec4f,    // p0.xy, p1.xy (px)
      @location(1) @interpolate(flat) radii: vec4f,   // core0, core1, blur0, blur1 (px)
      @location(2) @interpolate(flat) color: vec4f,   // radiance (fibres, discs) or albedo (solids), opacity
      @location(3) @interpolate(flat) info: vec4f,    // shape, translucency, extra, 0
      @location(4) @interpolate(flat) side: vec4f,    // solids: world side along the screen normal
      @location(5) @interpolate(flat) toward: vec4f,  // solids: world direction to the eye
      @location(6) @interpolate(flat) mid: vec4f,     // solids: world midpoint
    };
    
    fn nothing() -> Piece {
      return Piece(-1.0, vec3f(0.0), vec3f(0.0), 0.0, 0.0, vec3f(0.0), 0.0, 0.0, 0.0);
    }
    
    fn basis(axis: vec3f) -> mat2x3f {
      let reference = select(vec3f(0.0, 1.0, 0.0), vec3f(1.0, 0.0, 0.0), abs(axis.y) > 0.9);
      let e1 = normalize(cross(axis, reference));
      return mat2x3f(e1, cross(axis, e1));
    }
    
    /** Filament k leaves the hub at `theta` from the beak's axis; returns its unit direction. */
    fn filamentDir(k: u32, axis: vec3f, frame: mat2x3f, spin: f32, theta: f32, h: f32, wet: f32) -> vec3f {
      let fk = f32(k);
      let jitter = hash21(vec2f(fk, h * 97.0));
      var phi = TAU * (fk + 0.4 * jitter) / f32(FIBERS) + spin;
      // Wet filaments stick together in a few wisps.
      let clumps = 7.0;
      phi = mix(phi, (floor(phi * clumps / TAU) + 0.5) * TAU / clumps, wet * 0.55);
      return axis * cos(theta) + (frame[0] * cos(phi) + frame[1] * sin(phi)) * sin(theta);
    }
    
    /** The shape of a filament: hub, elbow and tip, curling up toward its tip like a shallow cup. */
    struct Filament { hub: vec3f, elbow: vec3f, tip: vec3f };
    fn filament(k: u32, hub: vec3f, axis: vec3f, frame: mat2x3f, spin: f32, base: f32, curl: f32, reach: f32, h: f32, wet: f32, flutter: f32) -> Filament {
      let fk = f32(k);
      let spread = 0.14 * (hash21(vec2f(fk * 1.7, h * 31.0)) - 0.5) + flutter * sin(fk * 2.3 + h * 50.0);
      let span = reach * (0.86 + 0.24 * hash21(vec2f(fk * 3.1, h * 7.0)));
      let start = hub + axis * 0.00012;
      let elbow = start + filamentDir(k, axis, frame, spin, base + spread, h, wet) * span * 0.5;
      let tip = elbow + filamentDir(k, axis, frame, spin, base + spread - curl, h, wet) * span * 0.5;
      return Filament(start, elbow, tip);
    }
    
    fn seedPiece(base: u32, sub: u32) -> Piece {
      let a = slots[base];
      let b = slots[base + 1u];
      let c = slots[base + 2u];
      let d = slots[base + 3u];
      let hub = a.xyz;
      let axis = normalize(b.xyz);
      let open = b.w;
      let spin = c.x;
      let size = c.y;
      let h = c.z;
      let alpha = c.w;
      let wet = d.x;
      let lod = d.y;
      let attached = d.z < 0.5;
      let frame = basis(axis);
      // Dry and free, the hairs spread almost flat and their tips rise (a shallow cup);
      // on the head neighbours hold them half-closed; wet, they fold into a brush.
      let theta = mix(0.5, select(1.52, mix(1.12, 1.4, 1.0 - scene.clock.w), attached), open);
      let curl = mix(0.04, select(0.36, 0.2, attached), open);
      let reach = PAPPUS * size;
      let time = scene.eye.w;
      let flutter = select(0.04, 0.012, attached) * scene.viewport.w * sin(time * (7.0 + 5.0 * h) + h * 20.0);
      let eye = scene.eye.xyz;
    
      if (sub == 0u) {
        if (lod <= 0.001) { return nothing(); }
        // Far or out of focus: the pappus as one soft disc of the same total light.
        let center = hub + axis * reach * (cos(theta) + cos(theta - curl)) * 0.3;
        let v = normalize(eye - center);
        let t0 = normalize(cross(axis, v) + frame[0] * 1e-3);
        let t1 = normalize(axis * cos(theta) + cross(t0, axis) * sin(theta));
        let t2 = normalize(axis * cos(theta) - cross(t0, axis) * sin(theta));
        let light = (fiberLight(t0, v, center, wet) + fiberLight(t1, v, center, wet) + fiberLight(t2, v, center, wet)) / 3.0;
        let radius = reach * max(sin(theta - curl * 0.5), 0.35) * 0.95;
        // A moving seed is drawn where it was during the whole exposure: a streak.
        let motion = slots[base + 4u];
        let trail = center - motion.xyz * motion.w;
        return Piece(SHAPE_STREAK, center, trail, radius, radius, light * vec3f(0.97, 0.96, 0.93), 0.09 * alpha * lod, 0.0, 0.0);
      }
      let detail = alpha * (1.0 - lod);
      if (detail <= 0.002) { return nothing(); }
    
      // The achene sits in its socket on the receptacle; in flight it hangs under the beak.
      var bottom = hub - axis * (BEAK + ACHENE) * size;
      var top = hub - axis * BEAK * size;
      if (attached) {
        let socket = scene.clock.xyz + normalize(hub - scene.clock.xyz) * RECEPTACLE;
        bottom = socket;
        top = socket + normalize(hub - socket) * ACHENE * size;
      }
      if (sub == 1u) {
        let tone = mix(vec3f(0.22, 0.17, 0.08), vec3f(0.34, 0.29, 0.16), hash11(h * 13.0));
        return Piece(SHAPE_SOLID, bottom, top, 0.00028 * size, 0.0004 * size, tone, detail, 0.35, 0.0);
      }
      if (sub == 2u) {
        let t = normalize(hub - top);
        let mid = (hub + top) * 0.5;
        let light = fiberLight(t, normalize(eye - mid), mid, wet) * vec3f(0.95, 0.93, 0.86);
        return Piece(SHAPE_FIBER, top, hub, BEAK_RADIUS, BEAK_RADIUS * 0.75, light, 0.85 * detail, 0.0, h);
      }
      if (sub < 3u + FIBERS * 2u) {
        let k = (sub - 3u) / 2u;
        let outer = (sub - 3u) % 2u == 1u;
        let f = filament(k, hub, axis, frame, spin, theta, curl, reach, h, wet, flutter);
        let p0 = select(f.hub, f.elbow, outer);
        let p1 = select(f.elbow, f.tip, outer);
        let dir = normalize(p1 - p0);
        let mid = (p0 + p1) * 0.5;
        let light = fiberLight(dir, normalize(eye - mid), mid, wet) * vec3f(0.98, 0.97, 0.94);
        // Hairs taper from about 30 µm at the base to 10 µm at the tip.
        let r0 = select(FIBER_RADIUS * 1.6, FIBER_RADIUS, outer);
        let r1 = select(FIBER_RADIUS, FIBER_RADIUS * 0.6, outer);
        return Piece(SHAPE_FIBER, p0, p1, r0, r1, light, 0.75 * detail, 0.0, h + f32(k));
      }
      // Two dew drops per pappus, strung on filaments.
      let j = sub - 3u - FIBERS * 2u;
      if (wet < 0.02) { return nothing(); }
      let k = u32(hash21(vec2f(f32(j) + 3.0, h * 17.0)) * f32(FIBERS)) % FIBERS;
      let f = filament(k, hub, axis, frame, spin, theta, curl, reach, h, wet, flutter);
      let along = 0.3 + 0.65 * hash21(vec2f(f32(j) * 5.0, h * 3.0));
      let center = select(mix(f.hub, f.elbow, along * 2.0), mix(f.elbow, f.tip, along * 2.0 - 1.0), along > 0.5);
      let radius = size * wet * (0.00007 + 0.0002 * hash21(vec2f(f32(j), h * 11.0)));
      return Piece(SHAPE_DROP, center, center, radius, radius, vec3f(1.0), detail * smoothstep(0.02, 0.25, wet), 0.0, h);
    }
    
    fn stemPoint(s: f32, base: vec3f, top: vec3f) -> vec3f {
      // A cantilever's shape: the scape bends most near its tip.
      let bend = (3.0 * s * s - s * s * s) * 0.5;
      return vec3f(base.x + (top.x - base.x) * bend, top.y * s, base.z + (top.z - base.z) * bend);
    }
    
    fn stemPiece(base: u32, sub: u32) -> Piece {
      let a = slots[base];
      let b = slots[base + 1u];
      let c = slots[base + 2u];
      let center = a.xyz;
      let up = normalize(b.xyz);
      let foot = vec3f(c.x, 0.0, c.y);
      let top = center - up * RECEPTACLE * 0.7;
      if (sub < STEM_SEGMENTS) {
        // The hollow scape: pale green and waxy, reddening toward the rosette, a little wider under the head.
        let s0 = f32(sub) / f32(STEM_SEGMENTS);
        let s1 = f32(sub + 1u) / f32(STEM_SEGMENTS);
        let r0 = scapeRadius(s0);
        let r1 = scapeRadius(s1);
        let tone = mix(vec3f(0.15, 0.07, 0.06), vec3f(0.1, 0.14, 0.065), smoothstep(0.05, 0.5, s0));
        return Piece(SHAPE_SOLID, stemPoint(s0, foot, top), stemPoint(s1, foot, top), r0, r1, tone, 1.0, 0.45, MATERIAL_SCAPE);
      }
      let frame = basis(up);
      var i = sub - STEM_SEGMENTS;
      if (i < STEM_HAIRS) {
        // Cobweb hairs just under the head, where the scape is woolly.
        let h = hash22(vec2f(f32(i), 7.7));
        let s = 0.955 + 0.04 * h.x;
        let psi = TAU * h.y;
        let radial = normalize((frame[0] * cos(psi) + frame[1] * sin(psi)) + up * 1e-3);
        let root = stemPoint(s, foot, top) + radial * scapeRadius(s);
        let dir = normalize(radial * 0.75 + up * (0.5 + 0.4 * hash11(f32(i) * 3.1)) + frame[0] * (hash11(f32(i) * 5.3) - 0.5) * 0.6);
        let tip = root + dir * (0.0012 + 0.0016 * hash11(f32(i) * 1.9));
        let mid = (root + tip) * 0.5;
        let light = fiberLight(dir, normalize(scene.eye.xyz - mid), mid, 0.0) * 0.8;
        return Piece(SHAPE_FIBER, root, tip, 0.000004, 0.000003, light, 0.55, 0.0, f32(i));
      }
      i -= STEM_HAIRS;
      // The bracts facing away from the lens are drawn before the receptacle, the others after it.
      if (i < BRACTS * 3u) { return bractPiece(i, center, up, frame, false); }
      i -= BRACTS * 3u;
      if (i == 0u) {
        // Sunk a little into its collar of bracts, the receptacle shows as a dome.
        let dome = center - up * RECEPTACLE * 0.25;
        return Piece(SHAPE_SPHERE, dome, dome, RECEPTACLE * 1.05, RECEPTACLE * 1.05, vec3f(0.5, 0.47, 0.33), 1.0, 0.2, 0.0);
      }
      i -= 1u;
      if (i < BRACTS * 3u) { return bractPiece(i, center, up, frame, true); }
      return nothing();
    }
    
    /**
     * Involucral bracts, reflexed down the scape once the clock has opened: narrow
     * strips, green with a paler midrib, hanging along the scape with purplish tips
     * curling away. `near` selects the bracts on the lens's side.
     */
    fn bractPiece(i: u32, center: vec3f, up: vec3f, frame: mat2x3f, near: bool) -> Piece {
      let j = i / 3u;
      let part = i % 3u;
      let psi = TAU * (f32(j) + 0.35 * hash11(f32(j) * 3.7)) / f32(BRACTS);
      let out = frame[0] * cos(psi) + frame[1] * sin(psi);
      if ((dot(out, scene.eye.xyz - center) > 0.0) != near) { return nothing(); }
      let stretch = 0.8 + 0.45 * hash11(f32(j) + 9.0);
      let inner = j % 2u == 1u;
      let p0 = center - up * RECEPTACLE * 0.62 + out * RECEPTACLE * select(0.82, 0.68, inner);
      let p1 = p0 + out * 0.0011 - up * 0.0019;
      let p2 = p1 + out * 0.0004 - up * 0.0048 * stretch;
      let p3 = p2 + out * 0.0011 - up * 0.0021 * stretch;
      let green = select(vec3f(0.045, 0.09, 0.03), vec3f(0.06, 0.1, 0.035), inner);
      let purple = vec3f(0.075, 0.05, 0.05);
      if (part == 0u) { return Piece(SHAPE_SOLID, p0, p1, 0.00045, 0.0007, green, 1.0, 0.3, MATERIAL_BRACT); }
      if (part == 1u) { return Piece(SHAPE_SOLID, p1, p2, 0.0007, 0.00058, mix(green, purple, 0.3), 1.0, 0.3, MATERIAL_BRACT); }
      return Piece(SHAPE_SOLID, p2, p3, 0.00058, 0.0001, purple, 1.0, 0.3, MATERIAL_BRACT);
    }
    
    /** Outer radius of the scape (m) along its length: 4–5 mm across, swelling a little under the head. */
    fn scapeRadius(s: f32) -> f32 {
      return mix(0.0022, 0.0016, s) + 0.00045 * smoothstep(0.9, 1.0, s);
    }
    
    fn bladePiece(base: u32, sub: u32) -> Piece {
      if (sub >= BLADE_SEGMENTS) { return nothing(); }
      let a = slots[base];
      let b = slots[base + 1u];
      let c = slots[base + 2u];
      let foot = a.xyz;
      let lean = b.xyz;
      let height = b.w;
      let width = c.x;
      let h = c.y;
      let time = scene.eye.w;
      let droop = 0.15 + 0.35 * h;
      let sway = scene.gust.xyz * scene.wind.w * 0.011 * (1.0 + 0.45 * sin(time * (1.4 + h) + h * 30.0)) * scene.viewport.w;
      let s0 = f32(sub) / f32(BLADE_SEGMENTS);
      let s1 = f32(sub + 1u) / f32(BLADE_SEGMENTS);
      let flat = normalize(vec3f(lean.x, 0.0, lean.z) + vec3f(1e-4, 0.0, 0.0));
      let p0 = foot + vec3f(0.0, height * (s0 - droop * s0 * s0 * s0), 0.0) + flat * height * (0.2 * s0 + droop * 1.2 * s0 * s0) + sway * s0 * s0;
      let p1 = foot + vec3f(0.0, height * (s1 - droop * s1 * s1 * s1), 0.0) + flat * height * (0.2 * s1 + droop * 1.2 * s1 * s1) + sway * s1 * s1;
      let r0 = width * 0.5 * (1.0 - 0.85 * pow(s0, 1.5));
      let r1 = width * 0.5 * (1.0 - 0.85 * pow(s1, 1.5));
      let tone = mix(vec3f(0.04, 0.09, 0.018), vec3f(0.11, 0.15, 0.035), s0 * 0.6 + 0.4 * h);
      return Piece(SHAPE_SOLID, p0, p1, r0, r1, tone, 1.0, 0.55, 0.0);
    }
    
    /**
     * A midge, 2–3 mm long: a dark, slender body (the thorax the thickest part), a
     * faint oval of beating wing on each side that the low sun lights from behind,
     * and two trailing legs, all dragged along its flight over the exposure (the
     * same light, spread thinner).
     */
    fn midgePiece(base: u32, sub: u32) -> Piece {
      let a = slots[base];
      let b = slots[base + 1u];
      let c = slots[base + 2u];
      let p = a.xyz;
      let travel = b.xyz * b.w;
      let size = c.x;
      let h = c.y;
      let beating = c.z;
      let speed = length(travel);
      let heading = select(normalize(vec3f(cos(h * TAU), 0.15, sin(h * TAU))), travel / max(speed, 1e-6), speed > size * 0.05);
      let across = normalize(cross(heading, vec3f(0.0, 1.0, 0.0)) + vec3f(1e-4, 0.0, 0.0));
      // Dragged over the exposure, the same light spreads over a longer smear.
      let smear = size / (size + speed);
      let toEye = normalize(scene.eye.xyz - p);
      let backlit = phaseHG(dot(-toEye, scene.sunDir.xyz), 0.6) * clockShadow(p);
      if (sub < 2u) {
        let side = select(-1.0, 1.0, sub == 0u);
        let wing = p + across * side * size * 0.32 + vec3f(0.0, size * 0.08, 0.0);
        let glow = scene.sun.rgb * (0.03 + 0.3 * backlit) + ambient(vec3f(0.0, 1.0, 0.0)) * 0.2;
        return Piece(SHAPE_STREAK, wing, wing - travel, size * 0.26, size * 0.26, glow * vec3f(0.95, 0.96, 1.0), 0.1 * beating, 0.0, 0.0);
      }
      if (sub == 2u) {
        return Piece(SHAPE_SOLID, p + heading * size * 0.45, p - heading * size * 0.55 - travel, size * 0.15, size * 0.06,
          vec3f(0.022, 0.02, 0.018), min(1.0, smear * 1.6), 0.25, 0.0);
      }
      // Two long legs trailing down and back.
      let side = select(-1.0, 1.0, sub == 3u);
      let root = p - heading * size * 0.1;
      let tip = root + (-heading * 0.6 + vec3f(0.0, -0.65, 0.0) + across * side * 0.25) * size;
      let dark = vec3f(0.012) + scene.sun.rgb * backlit * 0.04;
      return Piece(SHAPE_FIBER, root, tip, 0.000014, 0.00001, dark, 0.8 * smear * smear, 0.0, h * 97.0 + side);
    }
    
    fn flowerPiece(base: u32, sub: u32, clock: bool) -> Piece {
      let a = slots[base];
      let b = slots[base + 1u];
      let c = slots[base + 2u];
      let center = a.xyz;
      let facing = normalize(b.xyz);
      let radius = b.w;
      let open = c.x;
      let h = c.y;
      let eye = scene.eye.xyz;
      if (sub == 0u) {
        if (clock) {
          let v = normalize(eye - center);
          let t = normalize(cross(v, vec3f(0.0, 1.0, 0.0)) + vec3f(0.0, 1e-3, 0.0));
          let t2 = normalize(cross(v, t));
          let light = (fiberLight(t, v, center + t * CLOCK_R, 0.0) + fiberLight(t2, v, center + t2 * CLOCK_R, 0.0)) * 0.5;
          return Piece(SHAPE_DISC, center, center, radius, radius, light, 0.55, 0.0, h);
        }
        // A dandelion in flower: shut green at dawn, gold once the sun is on it.
        let petals = clamp(open * (1.15 - 0.3 * h), 0.0, 1.0);
        let albedo = mix(vec3f(0.18, 0.26, 0.07), vec3f(0.95, 0.6, 0.03), petals);
        let lit = albedo * (scene.sun.rgb * (max(dot(facing, scene.sunDir.xyz), 0.0) * 0.7 + 0.25) + ambient(facing));
        return Piece(SHAPE_DISC, center, center, radius * (0.42 + 0.58 * petals), radius, lit, 1.0, 0.0, h);
      }
      if (sub <= FLOWER_STEM) {
        let s0 = f32(sub - 1u) / f32(FLOWER_STEM);
        let s1 = f32(sub) / f32(FLOWER_STEM);
        let lean = vec3f(0.02 * (h - 0.5), 0.0, 0.015);
        let p0 = vec3f(center.x, center.y * (1.0 - s0), center.z) + lean * s0 * s0;
        let p1 = vec3f(center.x, center.y * (1.0 - s1), center.z) + lean * s1 * s1;
        return Piece(SHAPE_SOLID, p0 - vec3f(0.0, radius * 0.3, 0.0) * (1.0 - s0), p1 - vec3f(0.0, radius * 0.3, 0.0) * (1.0 - s1), 0.0018, 0.002, vec3f(0.18, 0.3, 0.09), 1.0, 0.7, 0.0);
      }
      return nothing();
    }
    
    fn toPixels(v: vec3f) -> vec2f {
      let clip = scene.proj * vec4f(v, 1.0);
      let ndc = clip.xy / clip.w;
      return vec2f((ndc.x * 0.5 + 0.5) * scene.viewport.x, (0.5 - ndc.y * 0.5) * scene.viewport.y);
    }
    
    fn hidden() -> Prim {
      var prim: Prim;
      prim.position = vec4f(2.0, 2.0, 0.5, 1.0);
      prim.color = vec4f(0.0);
      prim.info = vec4f(-1.0);
      return prim;
    }
    
    /** The slot an instance belongs to: the last whose first primitive is at or before it. */
    fn slotOf(instance: u32) -> u32 {
      var lo = 0u;
      var hi = MAX_SLOTS;
      for (var i = 0u; i < SLOT_SEARCH; i++) {
        let mid = (lo + hi) / 2u;
        if (starts[mid] <= instance) { lo = mid; } else { hi = mid; }
      }
      return lo;
    }
    
    @vertex fn vs_prim(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Prim {
      let slot = slotOf(instance);
      let sub = instance - starts[slot];
      let base = slot * SLOT_VEC4S;
      let kind = u32(slots[base].w + 0.5);
      var piece = nothing();
      switch kind {
        case KIND_SEED: { piece = seedPiece(base, sub); }
        case KIND_STEM: { piece = stemPiece(base, sub); }
        case KIND_BLADE: { piece = bladePiece(base, sub); }
        case KIND_FLOWER: { piece = flowerPiece(base, sub, false); }
        case KIND_CLOCK: { piece = flowerPiece(base, sub, true); }
        case KIND_MIDGE: { piece = midgePiece(base, sub); }
        default: {}
      }
      if (piece.shape < 0.0 || piece.opacity <= 0.0) { return hidden(); }
    
      var v0 = (scene.view * vec4f(piece.p0, 1.0)).xyz;
      var v1 = (scene.view * vec4f(piece.p1, 1.0)).xyz;
      var d0 = -v0.z;
      var d1 = -v1.z;
      if (d0 < NEAR && d1 < NEAR) { return hidden(); }
      // Clip a segment crossing the near plane.
      if (d0 < NEAR) { v0 = mix(v0, v1, (NEAR - d0) / (d1 - d0)); d0 = NEAR; }
      if (d1 < NEAR) { v1 = mix(v1, v0, (NEAR - d1) / (d0 - d1)); d1 = NEAR; }
      let focal = scene.lens.z;
      let q0 = toPixels(v0);
      let q1 = toPixels(v1);
      let core0 = piece.r0 * focal / d0;
      let core1 = piece.r1 * focal / d1;
      let blur0 = min(blurAt(d0), MAX_BLUR);
      let blur1 = min(blurAt(d1), MAX_BLUR);
      let reach0 = core0 + blur0 + 1.0;
      let reach1 = core1 + blur1 + 1.0;
    
      var along = q1 - q0;
      let span = length(along);
      along = select(vec2f(1.0, 0.0), along / max(span, 1e-6), span > 1e-4);
      let normal = vec2f(-along.y, along.x);
      let u = array<f32, 6>(0.0, 0.0, 1.0, 1.0, 0.0, 1.0)[vertex];
      let w = array<f32, 6>(-1.0, 1.0, -1.0, -1.0, 1.0, 1.0)[vertex];
      let reach = mix(reach0, reach1, u);
      let corner = mix(q0 - along * reach0, q1 + along * reach1, u) + normal * w * reach;
    
      var prim: Prim;
      prim.position = vec4f(corner.x / scene.viewport.x * 2.0 - 1.0, 1.0 - corner.y / scene.viewport.y * 2.0, 0.5, 1.0);
      prim.ends = vec4f(q0, q1);
      prim.radii = vec4f(core0, core1, blur0, blur1);
      prim.color = vec4f(piece.color, piece.opacity);
      prim.info = vec4f(piece.shape, piece.translucency, piece.extra, 0.0);
      let mid = (piece.p0 + piece.p1) * 0.5;
      let tangent = normalize(piece.p1 - piece.p0 + vec3f(1e-7, 0.0, 0.0));
      var side = camRight() * normal.x - camUp() * normal.y;
      side = normalize(side - tangent * dot(side, tangent) + vec3f(0.0, 1e-6, 0.0));
      prim.side = vec4f(side, 0.0);
      prim.toward = vec4f(normalize(scene.eye.xyz - mid), 0.0);
      prim.mid = vec4f(mid, 0.0);
      return prim;
    }
    
    /** Shading of a small sphere: a dew drop (reflection, the world inverted inside, the sun's glint). */
    fn dropShade(n: vec3f, v: vec3f) -> vec3f {
      let cosV = max(dot(n, v), 0.0);
      let fresnel = 0.02 + 0.98 * pow(1.0 - cosV, 5.0);
      let reflected = environment(reflect(-v, n));
      // A drop is a lens: it shows the world upside down, sky at its foot.
      let inside = environment(normalize(vec3f(-n.x, -n.y * 0.8 + 0.2, -n.z))) * 0.9;
      let l = scene.sunDir.xyz;
      let glint = pow(max(dot(reflect(-v, n), l), 0.0), 900.0) * 60.0;
      // Backlit, it focuses the sun toward the eye.
      let focus = pow(max(dot(-v, l), 0.0), 20.0) * exp(-sq(length(n.xy - (-l).xy * 0.4)) * 6.0) * 6.0;
      return mix(inside, reflected, fresnel) + scene.sun.rgb * (glint + focus);
    }
    
    /** The bare receptacle: pale, pitted where every achene sat. */
    /**
     * The bare receptacle: a pale dome dimpled with the seats where the achenes sat,
     * about a millimetre apart, each a small pit with a lighter raised rim; greener
     * toward its base, where the bracts spring.
     */
    fn receptacleShade(n: vec3f, v: vec3f, p: vec3f) -> vec3f {
      let cellular = n * 4.6;
      let cell = floor(cellular);
      var nearest = 2.0;
      var toward = vec3f(0.0);
      for (var z = -1; z <= 1; z++) {
        for (var y = -1; y <= 1; y++) {
          for (var x = -1; x <= 1; x++) {
            let o = vec3f(f32(x), f32(y), f32(z));
            let q = cell + o + 0.15 + 0.7 * hash33(cell + o);
            let d = length(cellular - q);
            if (d < nearest) { nearest = d; toward = cellular - q; }
          }
        }
      }
      let pit = smoothstep(0.34, 0.08, nearest);
      let rim = smoothstep(0.24, 0.36, nearest) * smoothstep(0.52, 0.38, nearest);
      let base = smoothstep(0.35, -0.4, n.y);
      // Straw to pale green, the seats a shade darker, their rims catching the light.
      let straw = mix(vec3f(0.44, 0.42, 0.27), vec3f(0.52, 0.48, 0.3), hash31(cell));
      var albedo = mix(straw, straw * vec3f(0.62, 0.6, 0.55), pit) * (1.0 + 0.12 * rim);
      albedo = mix(albedo, vec3f(0.16, 0.24, 0.09), base);
      // Tilt the normal into each pit so it catches the light like a dimple.
      let bumped = normalize(n - toward * 0.3 * (pit - rim * 0.5));
      return plantLight(bumped, v, p, albedo, 0.3);
    }
    
    @fragment fn fs_prim(prim: Prim) -> @location(0) vec4f {
      let shape = prim.info.x;
      if (shape < 0.0) { discard; }
      let p = prim.position.xy;
      let q0 = prim.ends.xy;
      if (shape < 1.5) {
        let segment = prim.ends.zw - q0;
        let len2 = max(dot(segment, segment), 1e-6);
        let t = clamp(dot(p - q0, segment) / len2, 0.0, 1.0);
        let offset = p - (q0 + segment * t);
        let dist = length(offset);
        let core = mix(prim.radii.x, prim.radii.y, t);
        let blur = mix(prim.radii.z, prim.radii.w, t);
        let inner = max(core - blur, 0.0);
        let outer = core + blur + 0.75;
        let coverage = 1.0 - smoothstep(inner, outer, dist);
        // Same total light whatever the blur: the peak drops as the profile widens.
        let peak = min(1.0, 2.0 * core / (inner + outer));
        let alpha = coverage * peak * prim.color.a;
        if (alpha <= 0.0005) { discard; }
        if (shape < 0.5) {
          // Barbs along a filament catch the light in beads.
          let bead = 0.8 + 0.4 * hash11(floor(t * 14.0) + prim.info.z * 13.0);
          return vec4f(prim.color.rgb * bead * alpha, alpha);
        }
        // A solid: shade the round section it would show at this point.
        let along = segment * inverseSqrt(len2);
        let normal2 = vec2f(-along.y, along.x);
        let across = clamp(dot(offset, normal2) / max(core + blur * 0.6, 0.5), -1.0, 1.0) * (core / (core + blur + 1e-3));
        let n = normalize(prim.side.xyz * across + prim.toward.xyz * sqrt(max(1.0 - across * across, 0.0)));
        let material = prim.info.z;
        var albedo = prim.color.rgb;
        if (material > 0.5) {
          // Fine ribs along the scape, a paler midrib down each bract.
          let around = asin(clamp(across, -1.0, 1.0));
          let ribs = select(0.5 + 0.5 * cos(around * 16.0), exp(-sq(across * 5.0)), material > 1.5);
          albedo *= select(0.86 + 0.22 * ribs, 0.9 + 0.5 * ribs, material > 1.5);
        }
        var rgb = plantLight(n, prim.toward.xyz, prim.mid.xyz, albedo, prim.info.y);
        if (material > 0.5 && material < 1.5) {
          // A waxy bloom: a thin anisotropic sheen running along the scape (Kajiya–Kay), and
          // light glowing through its green walls at the silhouette when the sun is behind.
          let t = normalize(cross(prim.side.xyz, prim.toward.xyz) + vec3f(0.0, 1e-5, 0.0));
          let h = normalize(scene.sunDir.xyz + prim.toward.xyz);
          let th = dot(t, h);
          let sheen = pow(max(1.0 - th * th, 0.0), 60.0) * 0.18;
          let edge = pow(abs(across), 3.0) * pow(max(dot(-prim.toward.xyz, scene.sunDir.xyz), 0.0), 2.0) * 0.5;
          rgb += scene.sun.rgb * clockShadow(prim.mid.xyz) * (sheen + edge * vec3f(0.55, 0.75, 0.25));
        }
        return vec4f(rgb * alpha, alpha);
      }
      // Discs, streaks, drops and the receptacle.
      var offset = p - q0;
      var smear = 1.0;
      if (shape > 4.5) {
        // A disc dragged along its path: same light, spread over the streak.
        let path = prim.ends.zw - q0;
        let span2 = dot(path, path);
        offset = p - (q0 + path * clamp(dot(p - q0, path) / max(span2, 1e-6), 0.0, 1.0));
        let reach = max(prim.radii.x, prim.radii.z) + 0.5;
        smear = 2.0 * reach / (2.0 * reach + sqrt(span2));
      }
      let dist = length(offset);
      let core = prim.radii.x;
      let blur = prim.radii.z;
      let inner = abs(core - blur);
      let outer = core + blur + 0.7;
      let coverage = 1.0 - smoothstep(inner, outer, dist);
      let peak = min(1.0, sq(core) / sq(max(max(core, blur), 0.5)));
      var alpha = coverage * peak * prim.color.a * smear;
      if (alpha <= 0.0005) { discard; }
      var rgb = prim.color.rgb;
      let v = prim.toward.xyz;
      let sharp = clamp(1.0 - blur / max(core, 1e-3), 0.0, 1.0);
      if (shape > 2.5 && shape < 4.5) {
        let local = offset / max(core, 1e-3);
        let r2 = dot(local, local);
        let z = sqrt(max(1.0 - r2, 0.0));
        let n = normalize(camRight() * local.x - camUp() * local.y + v * z);
        if (shape > 3.5) {
          let surface = receptacleShade(n, v, prim.mid.xyz + n * RECEPTACLE);
          let average = plantLight(v, v, prim.mid.xyz, vec3f(0.5, 0.48, 0.34), 0.2);
          rgb = mix(average, surface, sharp);
        } else {
          let average = environment(-v) * 0.5 + environment(v) * 0.3 + scene.sun.rgb * (0.06 + 1.5 * pow(max(dot(-v, scene.sunDir.xyz), 0.0), 20.0));
          rgb = mix(average, dropShade(n, v), sharp);
          alpha *= 0.9;
        }
      } else if (blur > core) {
        // Bokeh: a touch brighter at the rim.
        rgb *= 1.0 + 0.25 * smoothstep(outer * 0.55, outer * 0.95, dist);
      }
      return vec4f(rgb * alpha, alpha);
    }
  • motes.wgslvertexfragment Dust and pollen adrift in the sunlight around the clock: specks far too small to see, except that they scatter the low sun forward, and a macro lens turns each one it does not focus on into a small disc of light. Added, not blended. 53 lignes
    Fichier
    src/pissenlit/motes.wgsl
    Points d’entrée
    vs_mote vertex, fs_mote fragment
    Ressources
    0 liaison
    // Dust and pollen adrift in the sunlight around the clock: specks far too small
    // to see, except that they scatter the low sun forward, and a macro lens turns
    // each one it does not focus on into a small disc of light. Added, not blended.
    
    const MOTE_BOX: vec3f = vec3f(1.4, 0.55, 1.4);
    
    struct Mote {
      @builtin(position) position: vec4f,
      @location(0) @interpolate(flat) center: vec4f,  // px, radius (px), unused
      @location(1) @interpolate(flat) color: vec4f,
    };
    
    @vertex fn vs_mote(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Mote {
      var mote: Mote;
      mote.position = vec4f(2.0, 2.0, 0.5, 1.0);
      let dust = scene.meadow.w * scene.sunDir.w;
      if (dust <= 0.001) { return mote; }
      let i = f32(instance);
      let h = hash33(vec3f(i * 0.713, i * 1.371 + 3.1, i * 2.177 + 7.3));
      let time = scene.eye.w;
      let origin = scene.clock.xyz - MOTE_BOX * vec3f(0.5, 0.62, 0.5);
      // Carried by the air, with a slow wander of their own.
      let wander = vec3f(sin(time * (0.3 + h.x * 0.4) + h.y * 20.0), sin(time * (0.23 + h.z * 0.3) + h.x * 13.0) * 0.6, cos(time * (0.27 + h.y * 0.35) + h.z * 9.0)) * 0.05;
      let p = origin + fract(h + (scene.wind.xyz * (0.7 + 0.3 * h.y) + wander) / MOTE_BOX) * MOTE_BOX;
      let v = (scene.view * vec4f(p, 1.0)).xyz;
      let depth = -v.z;
      if (depth < 0.02) { return mote; }
      let blur = blurAt(depth);
      if (blur > 70.0) { return mote; }
      let clip = scene.proj * vec4f(v, 1.0);
      let ndc = clip.xy / clip.w;
      let px = vec2f((ndc.x * 0.5 + 0.5) * scene.viewport.x, (0.5 - ndc.y * 0.5) * scene.viewport.y);
      let toEye = normalize(scene.eye.xyz - p);
      // Strong forward scattering: they show when you look toward the sun.
      let scatter = phaseHG(dot(-toEye, scene.sunDir.xyz), 0.86) * clockShadow(p);
      let radius = max(blur, 0.7);
      let energy = 0.00055 * (0.4 + h.z) * dust / (radius * radius);
      mote.color = vec4f(scene.sun.rgb * scatter * energy * 40.0, 1.0);
      mote.center = vec4f(px, radius, 0.0);
      let corner = array<vec2f, 6>(vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0), vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0))[vertex];
      let q = px + corner * (radius + 1.0);
      mote.position = vec4f(q.x / scene.viewport.x * 2.0 - 1.0, 1.0 - q.y / scene.viewport.y * 2.0, 0.5, 1.0);
      return mote;
    }
    
    @fragment fn fs_mote(mote: Mote) -> @location(0) vec4f {
      let d = length(mote.position.xy - mote.center.xy);
      let r = mote.center.z;
      let disc = 1.0 - smoothstep(r - min(r * 0.3, 1.0), r + 0.6, d);
      if (disc <= 0.0) { discard; }
      let rim = 1.0 + 0.3 * smoothstep(r * 0.5, r, d) * step(3.0, r);
      return vec4f(mote.color.rgb * disc * rim, 0.0);
    }
  • present.wgslfragment Final image: exposure, bloom, ACES filmic curve, a soft vignette, then sRGB with a little noise so the dawn gradients never band on 8-bit screens. 29 lignes
    Fichier
    src/pissenlit/present.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    3 liaisons
    // Final image: exposure, bloom, ACES filmic curve, a soft vignette, then sRGB
    // with a little noise so the dawn gradients never band on 8-bit screens.
    
    @group(0) @binding(1) var hdr: texture_2d<f32>;
    @group(0) @binding(2) var bloom: texture_2d<f32>;
    @group(0) @binding(3) var clampSampler: sampler;
    
    fn aces(x: vec3f) -> vec3f {
      return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3f(0.0), vec3f(1.0));
    }
    
    fn toSrgb(c: vec3f) -> vec3f {
      return select(1.055 * pow(c, vec3f(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3f(0.0031308));
    }
    
    fn grain(pixel: vec2f, frame: f32) -> f32 {
      return fract(52.9829189 * fract(dot(pixel + frame * vec2f(5.588238, 5.588238), vec2f(0.06711056, 0.00583715))));
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let image = textureSampleLevel(hdr, clampSampler, uv, 0.0).rgb;
      let glow = textureSampleLevel(bloom, clampSampler, uv, 0.0).rgb;
      var color = (image + glow * scene.ground.w) * scene.zenith.w;
      let centered = uv - 0.5;
      color *= 1.0 - 0.28 * smoothstep(0.2, 0.9, dot(centered, centered) * 2.0);
      color = toSrgb(aces(color));
      let noise = (grain(position.xy, fract(scene.eye.w * 7.0) * 64.0) + grain(position.yx + 17.0, 3.0) - 1.0) / 255.0;
      return vec4f(color + noise * 1.5, 1.0);
    }
  • bloom.wgslPartagé, présenté avec Ce qui flotte

What the Sky Holds

WebGPU · WGSL · vgpu

6 shaders · 288 lignes · 1 partagé

Ouvrir la scène
  • common.wgslmodule Shared by every /essaim shader that reads the scene. 97 lignes
    Fichier
    src/essaim/common.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    1 liaison
    // Shared by every /essaim shader that reads the scene.
    
    struct Scene {
      view: mat4x4f,
      proj: mat4x4f,
      invViewProj: mat4x4f,
      eye: vec4f,       // observer on the beach, w: animation clock (s)
      viewport: vec4f,  // width and height (px), pixel ratio, exposure
      sea: vec4f,       // x: swell (0..1), y: reflection strength, z: glow of the far coast, w: drone count
      trail: vec4f,     // x: persistence of the previous frame (0: none), y: long exposure (0/1), z: bloom strength, w: motion allowed (0/1)
    };
    @group(0) @binding(0) var<uniform> scene: Scene;
    
    const PI: f32 = 3.14159265;
    
    fn hash2(p: vec2f) -> f32 {
      var q = fract(vec3f(p.x, p.y, p.x) * 0.1031);
      q += dot(q, q.yzx + 33.33);
      return fract((q.x + q.y) * q.z);
    }
    
    fn noise2(p: vec2f) -> f32 {
      let i = floor(p);
      let f = fract(p);
      let s = f * f * (3.0 - 2.0 * f);
      return mix(mix(hash2(i), hash2(i + vec2f(1, 0)), s.x), mix(hash2(i + vec2f(0, 1)), hash2(i + vec2f(1, 1)), s.x), s.y);
    }
    
    /** A moonless night: deep blue overhead, a little paler at the horizon, where the far coast glows. */
    fn nightSky(dir: vec3f) -> vec3f {
      let up = max(dir.y, 0.0);
      var color = mix(vec3f(0.0065, 0.0095, 0.02), vec3f(0.0012, 0.0022, 0.0065), pow(up, 0.38));
      let azimuth = atan2(dir.x, -dir.z);
      // Light from the towns along the shore on both sides, low and warm.
      let shore = 0.35 + 0.65 * smoothstep(0.25, 1.1, abs(azimuth));
      color += vec3f(0.022, 0.015, 0.012) * exp(-up * 30.0) * shore * scene.sea.z;
      return color;
    }
    
    fn stars(dir: vec3f) -> vec3f {
      if (dir.y < 0.015) { return vec3f(0.0); }
      let cell = floor(dir * 480.0);
      let seed = fract(sin(dot(cell, vec3f(12.9898, 78.233, 37.719))) * 43758.5453);
      if (seed < 0.9972) { return vec3f(0.0); }
      let center = (cell + 0.5) / 480.0;
      let d = length(dir - normalize(center)) * 480.0;
      let twinkle = 0.78 + 0.22 * sin(scene.eye.w * (1.3 + seed * 3.0) + seed * 90.0) * scene.trail.w;
      let tint = mix(vec3f(1.0, 0.86, 0.72), vec3f(0.78, 0.86, 1.0), fract(seed * 97.0));
      return tint * exp(-d * d * 3.0) * (seed - 0.9972) * 380.0 * twinkle * smoothstep(0.015, 0.16, dir.y);
    }
    
    /** Height of the far shore above the horizon (radians), only off to the sides. */
    fn shoreline(azimuth: f32) -> f32 {
      let side = smoothstep(0.42, 0.7, abs(azimuth));
      return side * (0.0025 + 0.004 * noise2(vec2f(azimuth * 6.0, 2.0)) + 0.002 * noise2(vec2f(azimuth * 30.0, 5.0)));
    }
    
    /** What the sea can mirror: sky and the dark shore dotted with town lights, but no stars (too faint to survive the water). */
    fn horizonLight(dir: vec3f) -> vec3f {
      let azimuth = atan2(dir.x, -dir.z);
      let top = shoreline(azimuth);
      if (dir.y > top) { return nightSky(dir); }
      var color = nightSky(vec3f(dir.x, 0.0, dir.z)) * 0.35;
      let spot = vec2f(azimuth * 900.0, (dir.y / max(top, 1e-4)) * 3.0);
      let light = hash2(floor(spot));
      if (light > 0.93) {
        let f = fract(spot) - 0.5;
        color += vec3f(1.0, 0.72, 0.42) * exp(-dot(f, f) * 24.0) * (light - 0.93) * 22.0 * scene.sea.z;
      }
      return color;
    }
    
    /** Everything above the sea, stars included. */
    fn above(dir: vec3f) -> vec3f {
      return horizonLight(dir) + stars(dir);
    }
    
    /** Height field of the swell: a few long waves and their ripples (meters). Returns (dh/dx, dh/dz). */
    fn swellSlope(p: vec2f, footprint: f32) -> vec2f {
      let t = scene.eye.w;
      var slope = vec2f(0.0);
      // The swell comes in toward the beach (+z), a little off-axis, so reflections stretch
      // and slide vertically, as a shore light's glitter path does, and only wobble sideways.
      let waves = array<vec4f, 5>(
        vec4f(0.14, 0.99, 26.0, 0.22), vec4f(-0.22, 0.975, 14.0, 0.12), vec4f(0.32, 0.95, 7.5, 0.06),
        vec4f(-0.4, 0.92, 3.8, 0.03), vec4f(0.5, 0.87, 2.1, 0.015));
      for (var i = 0; i < 5; i++) {
        let w = waves[i];
        let k = 2.0 * PI / w.z;
        // Waves much shorter than a pixel's footprint average out instead of sparkling.
        let fade = exp(-footprint / w.z * 1.4);
        let phase = dot(w.xy, p) * k + t * sqrt(9.81 * k);
        slope += w.xy * (w.w * k * cos(phase)) * fade;
      }
      // A calm night: slopes of a few hundredths, enough to stretch and break a reflection.
      return slope * (0.06 + 0.32 * scene.sea.x);
    }
  • sky.wgslfragment The night seen from the beach: sky, stars and the far shore above, and below them the sea. Each pixel of sea reflects the view ray off the swell: the reflected ray finds the sky (without its stars, too faint to survive the water) and, in the mirror layer, the drones' image. So the lights stretch, break and slide with the same waves everywhere. 56 lignes
    Fichier
    src/essaim/sky.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    3 liaisons
    // The night seen from the beach: sky, stars and the far shore above, and below them the
    // sea. Each pixel of sea reflects the view ray off the swell: the reflected ray finds the
    // sky (without its stars, too faint to survive the water) and, in the mirror layer, the
    // drones' image. So the lights stretch, break and slide with the same waves everywhere.
    
    @group(0) @binding(1) var mirror: texture_2d<f32>;
    @group(0) @binding(2) var mirrorMemory: texture_2d<f32>;
    @group(0) @binding(3) var clampSampler: sampler;
    
    /** The vertical plane the show hangs on (fleet.ts, SHOW_DISTANCE): parallax is exact for lights there. */
    const SHOW_PLANE: f32 = -320.0;
    /** Water reflects about a third of a grazing light; a little more keeps the show's reflection readable. */
    const MIRROR_GAIN: f32 = 1.5;
    
    fn rayDirection(uv: vec2f) -> vec3f {
      let far = scene.invViewProj * vec4f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 1.0, 1.0);
      return normalize(far.xyz / far.w - scene.eye.xyz);
    }
    
    /** The drones' light arriving along the reflected ray `d` from the water point `p`. */
    fn droneReflection(p: vec3f, d: vec3f) -> vec3f {
      // A light L is seen along p + d·t; its image in a flat sea lies on the mirrored ray, which
      // is what the mirror layer drew. Follow it to the show plane and look that point up.
      let m = vec3f(d.x, -d.y, d.z);
      if (m.z > -1e-4) { return vec3f(0.0); }
      let q = p + m * ((SHOW_PLANE - p.z) / m.z);
      let clip = scene.proj * (scene.view * vec4f(q, 1.0));
      if (clip.w <= 0.0) { return vec3f(0.0); }
      let uv = vec2f(clip.x / clip.w * 0.5 + 0.5, 0.5 - clip.y / clip.w * 0.5);
      if (any(uv < vec2f(0.0)) || any(uv > vec2f(1.0))) { return vec3f(0.0); }
      let now = textureSampleLevel(mirror, clampSampler, uv, 0.0).rgb;
      // Long exposure: the memory beyond the current lights, as faint as in the sky.
      let memory = textureSampleLevel(mirrorMemory, clampSampler, uv, 0.0).rgb;
      return now + max(memory - now, vec3f(0.0)) * 0.4 * scene.trail.y;
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let dir = rayDirection(uv);
      if (dir.y >= 0.0) { return vec4f(above(dir), 1.0); }
      let distance = scene.eye.y / max(-dir.y, 1e-5);
      let p = scene.eye.xyz + dir * distance;
      // One pixel covers this many meters of sea, stretched by the grazing angle.
      let footprint = distance * 1.4 / (scene.viewport.y * max(-dir.y, 0.02));
      let slope = swellSlope(p.xz, footprint);
      let normal = normalize(vec3f(-slope.x, 1.0, -slope.y));
      var reflected = reflect(dir, normal);
      reflected.y = abs(reflected.y);
      let cosine = clamp(dot(-dir, normal), 0.0, 1.0);
      let fresnel = 0.02 + 0.98 * pow(1.0 - cosine, 5.0);
      let deep = vec3f(0.002, 0.004, 0.009);
      var color = mix(deep, horizonLight(reflected) * scene.sea.y, fresnel);
      color += droneReflection(p, reflected) * fresnel * MIRROR_GAIN;
      // Toward the horizon the sea melts into the haze of the far coast.
      color = mix(color, nightSky(vec3f(dir.x, 0.0, dir.z)) * 0.8, 1.0 - exp(-distance / 5200.0));
      return vec4f(color, 1.0);
    }
  • drones.wgslvertex ×2fragment Every drone light: instanced quads (WebGPU points are one pixel), added into the HDR scene so no sorting is needed. `vs_mirror` draws the same lights mirrored in the sea (y → −y) into a half-resolution layer that the sea looks up through its swell. 68 lignes
    Fichier
    src/essaim/drones.wgsl
    Points d’entrée
    vs_drone vertex, vs_mirror vertex, fs_drone fragment
    Ressources
    1 liaison
    // Every drone light: instanced quads (WebGPU points are one pixel), added into the HDR
    // scene so no sorting is needed. `vs_mirror` draws the same lights mirrored in the sea
    // (y → −y) into a half-resolution layer that the sea looks up through its swell.
    
    @group(0) @binding(1) var<storage, read> drones: array<vec4f>;
    
    /** Radius of a light as seen from far away, glow included (m). */
    const RADIUS: f32 = 0.6;
    const INTENSITY: f32 = 9.0;
    
    struct Light {
      @builtin(position) position: vec4f,
      @location(0) corner: vec2f,
      @location(1) color: vec3f,
    };
    
    fn quadCorner(vertex: u32) -> vec2f {
      const corners = array<vec2f, 6>(vec2f(-1, -1), vec2f(1, -1), vec2f(-1, 1), vec2f(-1, 1), vec2f(1, -1), vec2f(1, 1));
      return corners[vertex % 6u];
    }
    
    fn hidden() -> Light {
      var out: Light;
      out.position = vec4f(2.0, 2.0, 2.0, 1.0);
      out.corner = vec2f(0.0);
      out.color = vec3f(0.0);
      return out;
    }
    
    /** A light at p, at least `minPx` full-resolution pixels across so nothing flickers between pixels. */
    fn light(p: vec3f, energy_: f32, color: vec3f, corner: vec2f, minPx: f32) -> Light {
      var energy = energy_;
      let view = scene.view * vec4f(p, 1.0);
      if (-view.z < 1.0 || energy < 0.002) { return hidden(); }
      var clip = scene.proj * view;
      var px = RADIUS * scene.proj[1][1] * 0.5 * scene.viewport.y / clip.w;
      // Far lights keep their energy but not their size.
      if (px < minPx) {
        energy *= (px * px) / (minPx * minPx);
        px = minPx;
      }
      clip.x += corner.x * px * 2.0 / scene.viewport.x * clip.w;
      clip.y += corner.y * px * 2.0 / scene.viewport.y * clip.w;
      var out: Light;
      out.position = clip;
      out.corner = corner;
      out.color = color * energy * INTENSITY;
      return out;
    }
    
    @vertex fn vs_drone(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Light {
      let a = drones[instance * 2u];
      let b = drones[instance * 2u + 1u];
      return light(a.xyz, a.w, b.rgb, quadCorner(vertex), 1.25 * scene.viewport.z);
    }
    
    /** The same light seen in a flat sea. Drawn a little larger: the layer is half resolution and read back through the swell. */
    @vertex fn vs_mirror(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Light {
      let a = drones[instance * 2u];
      let b = drones[instance * 2u + 1u];
      return light(vec3f(a.x, -a.y, a.z), a.w, b.rgb, quadCorner(vertex), 3.0 * scene.viewport.z);
    }
    
    @fragment fn fs_drone(input: Light) -> @location(0) vec4f {
      let r2 = dot(input.corner, input.corner);
      if (r2 > 1.0) { discard; }
      return vec4f(input.color * (exp(-r2 * 16.0) + 0.22 * exp(-r2 * 4.5)), 0.0);
    }
  • trail.wgslvertexfragment Long exposure: the light layer keeps a fading memory of previous frames, as a peak hold rather than a sum, so a drone hovering in place does not burn brighter and a paused frame redrawn stays the same. In half floats the memory fades all the way to black. 18 lignes
    Fichier
    src/essaim/trail.wgsl
    Points d’entrée
    vs_fullscreen vertex, accumulate fragment
    Ressources
    2 liaisons
    // Long exposure: the light layer keeps a fading memory of previous frames, as a peak hold
    // rather than a sum, so a drone hovering in place does not burn brighter and a paused frame
    // redrawn stays the same. In half floats the memory fades all the way to black.
    
    @group(0) @binding(1) var previous: texture_2d<f32>;
    @group(0) @binding(2) var lights: texture_2d<f32>;
    
    struct Fullscreen { @builtin(position) position: vec4f, @location(0) uv: vec2f };
    @vertex fn vs_fullscreen(@builtin(vertex_index) vertex: u32) -> Fullscreen {
      let corner = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0))[vertex];
      return Fullscreen(vec4f(corner, 0.0, 1.0), corner * vec2f(0.5, -0.5) + 0.5);
    }
    
    @fragment fn accumulate(input: Fullscreen) -> @location(0) vec4f {
      let pixel = vec2i(input.position.xy);
      let memory = textureLoad(previous, pixel, 0).rgb * scene.trail.x;
      return vec4f(max(memory, textureLoad(lights, pixel, 0).rgb), 1.0);
    }
  • composite.wgslvertexfragment Adds the long-exposure memory onto the scene before the bloom reads it. The trails live at half resolution (they are soft anyway); the drones of the current frame are already drawn sharp in the scene, so only what the memory holds beyond them is added. 20 lignes
    Fichier
    src/essaim/composite.wgsl
    Points d’entrée
    vs_fullscreen vertex, composite fragment
    Ressources
    3 liaisons
    // Adds the long-exposure memory onto the scene before the bloom reads it. The trails live
    // at half resolution (they are soft anyway); the drones of the current frame are already
    // drawn sharp in the scene, so only what the memory holds beyond them is added.
    
    @group(0) @binding(0) var trail: texture_2d<f32>;
    @group(0) @binding(1) var current: texture_2d<f32>;
    @group(0) @binding(2) var linearClamp: sampler;
    
    struct Fullscreen { @builtin(position) position: vec4f, @location(0) uv: vec2f };
    @vertex fn vs_fullscreen(@builtin(vertex_index) vertex: u32) -> Fullscreen {
      let corner = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0))[vertex];
      return Fullscreen(vec4f(corner, 0.0, 1.0), corner * vec2f(0.5, -0.5) + 0.5);
    }
    
    @fragment fn composite(input: Fullscreen) -> @location(0) vec4f {
      let memory = textureSampleLevel(trail, linearClamp, input.uv, 0.0).rgb;
      let now = textureSampleLevel(current, linearClamp, input.uv, 0.0).rgb;
      // A faint memory: with a thousand drones sweeping, full-strength trails would fill the sky.
      return vec4f(max(memory - now, vec3f(0.0)) * 0.4, 0.0);
    }
  • present.wgslfragment Final image: exposure, bloom, the ACES filmic curve, a soft vignette, then sRGB with a little noise so the long dark gradients of the night never band on 8-bit screens. 29 lignes
    Fichier
    src/essaim/present.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    3 liaisons
    // Final image: exposure, bloom, the ACES filmic curve, a soft vignette, then sRGB with a
    // little noise so the long dark gradients of the night never band on 8-bit screens.
    
    @group(0) @binding(1) var hdr: texture_2d<f32>;
    @group(0) @binding(2) var bloom: texture_2d<f32>;
    @group(0) @binding(3) var clampSampler: sampler;
    
    fn aces(x: vec3f) -> vec3f {
      return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3f(0.0), vec3f(1.0));
    }
    
    fn toSrgb(c: vec3f) -> vec3f {
      return select(1.055 * pow(c, vec3f(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3f(0.0031308));
    }
    
    fn noise(pixel: vec2f, frame: f32) -> f32 {
      return fract(52.9829189 * fract(dot(pixel + frame * vec2f(5.588238, 5.588238), vec2f(0.06711056, 0.00583715))));
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let base = textureSampleLevel(hdr, clampSampler, uv, 0.0).rgb;
      let glow = textureSampleLevel(bloom, clampSampler, uv, 0.0).rgb;
      var color = (base + glow * scene.trail.z) * scene.viewport.w;
      let centered = uv - 0.5;
      color *= 1.0 - 0.3 * smoothstep(0.25, 0.85, dot(centered, centered) * 2.0);
      color = toSrgb(aces(color));
      let dither = (noise(position.xy, fract(scene.eye.w * 7.0) * 64.0) + noise(position.yx + 17.0, 3.0) - 1.0) / 255.0;
      return vec4f(color + dither, 1.0);
    }
  • bloom.wgslPartagé, présenté avec Ce qui flotte

Strates

WebGPU · WGSL · vgpu

5 shaders · 461 lignes

Ouvrir la scène
  • common.wgslmodule Shared by the /strates shaders: the frame's uniforms, the plates' records and the outline's distance field (the same formulas as shapes.ts). 86 lignes
    Fichier
    src/strates/common.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    1 liaison
    // Shared by the /strates shaders: the frame's uniforms, the plates' records and
    // the outline's distance field (the same formulas as shapes.ts).
    
    struct Scene {
      viewProj: mat4x4f,
      invViewProj: mat4x4f,
      forward: vec4f,   // into the scene (unit), w: scene clock (s)
      light: vec4f,     // toward the lamp above the board (unit), w: plates in the stack
      viewport: vec4f,  // width, height (px), px per CSS px, px per world unit
      board0: vec4f,    // the board's gradient: sRGB stops at the top, a third, two thirds and the foot
      board1: vec4f,    //   of the frame; board0.w: dash period (px), board1.w: dash length (px)
      board2: vec4f,
      board3: vec4f,
      glass: vec4f,     // what a plate adds over the board (linear), w: how much of the board it hides
      edge: vec4f,      // its polished edge (linear), w: the hairline's σ (CSS px)
      guide: vec4f,     // construction lines (linear), w: their opacity
      shade: vec4f,     // shadow tint, w: shadow strength
      sheen: vec4f,     // the lamp's reflection in a tilted plate, w: scale of the plates' haze fade
      accent: vec4f,    // the glow of a run of aligned tiles (linear), w: 0 glass, 1 opaque tiles (eased between)
    };
    @group(0) @binding(0) var<uniform> scene: Scene;
    /** Depth covered by the view, far − near (camera.ts): a point `t` units past the near plane has depth t / DEPTH_SPAN. */
    const DEPTH_SPAN: f32 = 39.9;
    
    struct Plate {
      pos: vec4f,     // centre, w: half the drawn thickness
      rot: vec4f,     // orientation (quaternion)
      box0: vec4f,    // the outline's rounded box: centre (x, z), half extents
      radii0: vec4f,  // its corner radii (+x+z, +x−z, −x+z, −x−z)
      box1: vec4f,    // the box cut out of it (the chevron), or the half-plane cut (normal.xz, offset) of the triangle
      radii1: vec4f,
      rect: vec4f,    // screen bounds in NDC (min x, min y, max x, max y)
      form: vec4f,    // 1 plain, 2 cut by a box, 3 cut by a half-plane; blend of the cut, presence (0..1), highlight (0..1)
      tone: vec4f,    // haze brightness, its fade toward the viewer (per unit), how bright the side wall glows, glow of an aligned run (0..1)
    };
    
    fn sq(x: f32) -> f32 { return x * x; }
    
    fn quatRotate(q: vec4f, v: vec3f) -> vec3f {
      let t = 2.0 * cross(q.xyz, v);
      return v + q.w * t + cross(q.xyz, t);
    }
    
    fn quatUnrotate(q: vec4f, v: vec3f) -> vec3f {
      return quatRotate(vec4f(-q.xyz, q.w), v);
    }
    
    fn roundBox(p: vec2f, box: vec4f, radii: vec4f) -> f32 {
      let q0 = p - box.xy;
      let rx = select(radii.zw, radii.xy, q0.x > 0.0);
      let r = select(rx.y, rx.x, q0.y > 0.0);
      let q = abs(q0) - box.zw + r;
      return min(max(q.x, q.y), 0.0) + length(max(q, vec2f(0.0))) - r;
    }
    
    /** Smooth maximum: rounds the corners where a cut meets the outline. */
    fn smax(a: f32, b: f32, k: f32) -> f32 {
      if (k <= 0.0) { return max(a, b); }
      let h = max(k - abs(a - b), 0.0) / k;
      return max(a, b) + h * h * k * 0.25;
    }
    
    /** The plate's outline: its rounded box, less the cut when it has one (the chevron's box, the triangle's half-plane). */
    fn outline(plate: Plate, p: vec2f) -> f32 {
      let d = roundBox(p, plate.box0, plate.radii0);
      if (plate.form.x > 2.5) { return smax(d, dot(p, plate.box1.xy) - plate.box1.z, plate.form.y); }
      if (plate.form.x > 1.5) { return smax(d, -roundBox(p, plate.box1, plate.radii1), plate.form.y); }
      return d;
    }
    
    /** The board's colour behind a pixel: a studio gradient, the lamp at the back, a cubic through four stops. */
    fn paper(pixel: vec2f) -> vec3f {
      let t = clamp(pixel.y / scene.viewport.y, 0.0, 1.0);
      let a = t - 1.0 / 3.0;
      let b = t - 2.0 / 3.0;
      let c = t - 1.0;
      let srgb = scene.board0.rgb * (-4.5 * a * b * c) + scene.board1.rgb * (13.5 * t * b * c)
        + scene.board2.rgb * (-13.5 * t * a * c) + scene.board3.rgb * (4.5 * t * a * b);
      return pow(max(srgb, vec3f(0.0)), vec3f(2.2));
    }
    
    fn hash21(p: vec2f) -> f32 {
      var q = fract(p * vec2f(0.1031, 0.1030));
      q += dot(q, q.yx + 33.33);
      return fract((q.x + q.y) * q.x);
    }
  • background.wgslvertexfragment The drawing board: a dark studio gradient, and under it the soft shadows the plates cast from the lamp above, sharp near the board and gone a plate's width above it. At the reference's height the stack casts none. 56 lignes
    Fichier
    src/strates/background.wgsl
    Points d’entrée
    vs_board vertex, fs_main fragment
    Ressources
    1 liaison
    // The drawing board: a dark studio gradient, and under it the soft shadows the
    // plates cast from the lamp above, sharp near the board and gone a plate's width
    // above it. At the reference's height the stack casts none.
    
    @group(0) @binding(1) var<storage, read> plates: array<Plate>;
    
    fn boardPoint(pixel: vec2f) -> vec3f {
      let ndc = vec2f(pixel.x / scene.viewport.x * 2.0 - 1.0, 1.0 - pixel.y / scene.viewport.y * 2.0);
      let near = scene.invViewProj * vec4f(ndc, 0.0, 1.0);
      let dir = scene.forward.xyz;
      // The board is the plane y = 0.
      let t = -near.y / min(dir.y, -1e-3);
      return near.xyz + dir * t;
    }
    
    fn shadowAt(x: vec3f) -> f32 {
      var lit = 1.0;
      let count = u32(scene.light.w);
      for (var i = 0u; i < count; i++) {
        let plate = plates[i];
        let o = quatUnrotate(plate.rot, x - plate.pos.xyz);
        let l = quatUnrotate(plate.rot, scene.light.xyz);
        if (abs(l.y) < 1e-3) { continue; }
        let t = -o.y / l.y;
        // Beyond a plate's width above the board, a shadow is gone.
        if (t <= 0.0 || t > 1.6) { continue; }
        let p = o.xz + l.xz * t;
        // The lamp is broad: the penumbra grows with the gap between plate and board.
        let penumbra = 0.012 + t * 0.42;
        let d = outline(plate, p);
        let occlusion = 1.0 - smoothstep(-penumbra, penumbra, d);
        lit *= 1.0 - occlusion * exp(-t * 3.2) * plate.form.z;
      }
      return lit;
    }
    
    struct BoardOut {
      @builtin(position) position: vec4f,
      @location(0) uv: vec2f,
    };
    
    /** One triangle over the whole frame. Drawn as a plain draw, so it tests and writes no depth. */
    @vertex fn vs_board(@builtin(vertex_index) vertex: u32) -> BoardOut {
      let corners = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
      var out: BoardOut;
      out.position = vec4f(corners[vertex], 0.5, 1.0);
      out.uv = corners[vertex] * vec2f(0.5, -0.5) + 0.5;
      return out;
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let base = paper(position.xy);
      let lit = shadowAt(boardPoint(position.xy));
      let shaded = mix(base * scene.shade.rgb, base, mix(1.0, lit, scene.shade.w));
      return vec4f(shaded, 1.0);
    }
  • plates.wgslvertexfragment The glass plates, one screen rectangle each, drawn back to front. A plate is a thin slab: the pixel's ray is cut by its two faces, and the outline's distance field at both crossings gives the face, the polished edge (the thin bright line where light pipes through the glass) and the side wall that shows when the plate tilts. The face adds a faint warm haze and, turned toward the lamp, its reflection. With `scene.accent.w` the tiles turn opaque: a solid slab, its face lit by the lamp and its cut in shade, hiding what lies behind it. 223 lignes
    Fichier
    src/strates/plates.wgsl
    Points d’entrée
    vs_plate vertex, fs_plate fragment
    Ressources
    1 liaison
    // The glass plates, one screen rectangle each, drawn back to front. A plate is
    // a thin slab: the pixel's ray is cut by its two faces, and the outline's
    // distance field at both crossings gives the face, the polished edge (the thin
    // bright line where light pipes through the glass) and the side wall that shows
    // when the plate tilts. The face adds a faint warm haze and, turned toward the
    // lamp, its reflection. With `scene.accent.w` the tiles turn opaque: a solid slab, its
    // face lit by the lamp and its cut in shade, hiding what lies behind it.
    
    @group(0) @binding(1) var<storage, read> plates: array<Plate>;
    
    struct PlateOut {
      @builtin(position) position: vec4f,
      @location(0) @interpolate(flat) index: u32,
    };
    
    @vertex fn vs_plate(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> PlateOut {
      let corners = array<vec2f, 6>(vec2f(0.0, 0.0), vec2f(1.0, 0.0), vec2f(0.0, 1.0), vec2f(0.0, 1.0), vec2f(1.0, 0.0), vec2f(1.0, 1.0));
      let rect = plates[instance].rect;
      var out: PlateOut;
      out.position = vec4f(mix(rect.xy, rect.zw, corners[vertex]), 0.5, 1.0);
      out.index = instance;
      return out;
    }
    
    /** Coverage of the inside of an outline, antialiased over one pixel. */
    fn inside(d: f32, pixel: f32) -> f32 { return clamp(0.5 - d / pixel, 0.0, 1.0); }
    
    /**
     * Signed distance, in world units, of a near-horizontal ray to the slab seen from the side.
     * Negative inside. The oblique face shading misses this view: its two planes are parallel to the ray.
     * The ray is followed through the outline (in the plate's plane, `s` along it) and its height read
     * there: the origin lies on the near plane, far from the plate, where a ray a few degrees off the
     * horizontal, or a plate a tenth of a degree off level, is already several thicknesses away.
     */
    fn profileDistance(plate: Plate, o: vec3f, d: vec3f, h: f32) -> f32 {
      let run = max(length(d.xz), 1e-4);
      let dir2 = d.xz / run;
      // Height gained per unit travelled in the plane.
      let slope = d.y / run;
      let sCenter = -dot(o.xz, dir2);
      // Into the outline from the near side, keeping the closest approach for a miss.
      var sIn = sCenter - 1.8;
      var dist = outline(plate, o.xz + dir2 * sIn);
      var closest = dist;
      for (var i = 0u; i < 14u; i++) {
        if (dist <= 0.0) { break; }
        sIn += max(dist, 1e-4);
        if (sIn > sCenter + 1.8) { break; }
        dist = outline(plate, o.xz + dir2 * sIn);
        closest = min(closest, dist);
      }
      if (dist > 0.0) {
        // A miss keeps its distance so the silhouette can antialias.
        let w = vec2f(closest, abs(o.y + slope * sCenter) - h);
        return length(max(w, vec2f(0.0)));
      }
      // And from the far side, for the chord the ray spends inside the outline.
      var sOut = sCenter + 1.8;
      var back = outline(plate, o.xz + dir2 * sOut);
      for (var i = 0u; i < 14u; i++) {
        if (back <= 0.0 || sOut <= sIn) { break; }
        sOut -= max(back, 1e-4);
        back = outline(plate, o.xz + dir2 * sOut);
      }
      sOut = max(sOut, sIn);
      // Across the silhouette: half the chord, which vanishes at the outline's edge.
      let across = -0.5 * (sOut - sIn);
      // Up and down: how near the ray's height comes to the slab along that chord.
      let yIn = o.y + slope * sIn;
      let yOut = o.y + slope * sOut;
      let nearest = select(min(abs(yIn), abs(yOut)), 0.0, yIn * yOut <= 0.0);
      let w = vec2f(across, nearest - h);
      return min(max(w.x, w.y), 0.0) + length(max(w, vec2f(0.0)));
    }
    
    /** The opaque slab's body, in the paper's own terms: between its board and its edge, or its pigment. */
    fn slabColor() -> vec3f {
      let board = pow(max(scene.board1.rgb, vec3f(0.0)), vec3f(2.2));
      let pigment = smoothstep(0.08, 0.4, max(scene.glass.r, max(scene.glass.g, scene.glass.b)));
      return mix(mix(board, scene.edge.rgb, 0.34), scene.glass.rgb * 0.8, pigment);
    }
    
    /**
     * A plate's colour and the depth of what the pixel sees of it. The glass pipeline ignores the depth
     * and relies on the back-to-front order; the opaque one tests and writes it, so overlapping slabs
     * hide each other where they really do, whatever order their centres sort in.
     */
    struct PlateColor {
      @location(0) color: vec4f,
      @builtin(frag_depth) depth: f32,
    };
    
    @fragment fn fs_plate(in: PlateOut) -> PlateColor {
      let plate = plates[in.index];
      let pixel = in.position.xy;
      let ndc = vec2f(pixel.x / scene.viewport.x * 2.0 - 1.0, 1.0 - pixel.y / scene.viewport.y * 2.0);
      let origin = (scene.invViewProj * vec4f(ndc, 0.0, 1.0)).xyz;
      let dir = scene.forward.xyz;
      let o = quatUnrotate(plate.rot, origin - plate.pos.xyz);
      let d = quatUnrotate(plate.rot, dir);
      let h = plate.pos.w;
      // Side profile, mixed in at the end. It is not a branch around the face's derivatives: those must
      // run for every pixel. The glass stays translucent so an overlap darkens and a gap stays the background.
      let profile = profileDistance(plate, o, d, h);
      let pixelSize = 1.0 / max(scene.viewport.w, 1.0);
      let profileCover = inside(profile, pixelSize);
      let profileSheet = 1.0 - smoothstep(0.45, 0.85, plate.tone.z);
      let profileSigma = scene.edge.w * scene.viewport.z;
      let profileHair = exp(-0.5 * sq(profile / pixelSize / profileSigma));
      let profileGround = paper(pixel);
      let profilePigment = smoothstep(0.08, 0.4, max(scene.glass.r, max(scene.glass.g, scene.glass.b)));
      let profileTint = mix(mix(profileGround, scene.edge.rgb, 0.14), scene.glass.rgb, profilePigment);
      let profileCut = mix(profileTint, scene.edge.rgb, 0.42) * (1.0 + 0.25 * plate.form.w);
      let profileBody = mix(scene.glass.w, 0.5, profileSheet) * profileCover * smoothstep(0.45, 1.0, plate.form.z);
      let profileRim = min(profileHair, 1.0) * smoothstep(0.0, 0.08, plate.form.z);
      let profileAlpha = profileRim + profileBody * (1.0 - profileRim);
      let profileColor = vec4f(scene.edge.rgb * profileRim + profileCut * profileBody * (1.0 - profileRim), profileAlpha);
      // Opaque, the profile is the slab's cut: solid, in shade, its rims still drawn.
      let slab = slabColor();
      let solidCover = profileCover * smoothstep(0.45, 1.0, plate.form.z);
      let solidEdge = profileRim * 0.7;
      let solidProfile = vec4f(scene.edge.rgb * solidEdge + slab * 0.62 * solidCover * (1.0 - solidEdge), solidEdge + solidCover * (1.0 - solidEdge));
      let dy = select(min(d.y, -2e-3), max(d.y, 2e-3), d.y > 0.0);
      let tA = (h - o.y) / dy;
      let tB = (-h - o.y) / dy;
      let tNear = min(tA, tB);
      let tFar = max(tA, tB);
      let pNear = o.xz + d.xz * tNear;
      let pFar = o.xz + d.xz * tFar;
      let dNear = outline(plate, pNear);
      let dFar = outline(plate, pFar);
      // World units per pixel across each outline.
      let aaNear = max(length(vec2f(dpdx(dNear), dpdy(dNear))), 1e-6);
      let aaFar = max(length(vec2f(dpdx(dFar), dpdy(dFar))), 1e-6);
    
      let inNear = inside(dNear, aaNear);
      let inFar = inside(dFar, aaFar);
      let cover = max(inNear, inFar);
      // A tile (tone.z well below a pane's 1) is a closed sheet. Its underside line was reading as a hole through the face.
      let sheet = 1.0 - smoothstep(0.45, 0.85, plate.tone.z);
      // The polished edge: a soft hairline, Gaussian across (σ in pixels), like light bleeding from the glass's rim.
      let sigma = scene.edge.w * scene.viewport.z;
      let strokeNear = exp(-0.5 * sq(dNear / aaNear / sigma));
      let strokeFar = exp(-0.5 * sq(dFar / aaFar / sigma)) * mix(0.15, 0.8, 1.0 - plate.tone.z) * (1.0 - inNear * sheet);
      // Between the two outlines the ray crosses the side. A pane keeps that as its polished edge.
      // A tile's cut is the glass: the band is filled, so the two rims don't leave a slot.
      let side = abs(inNear - inFar);
      let wall = side * plate.tone.z;
      let highlight = plate.form.w;
      var rim = max(max(strokeNear, strokeFar), wall);
    
      // Drawn in: the edge traces from the far corner toward the viewer, then the glass fills.
      let presence = plate.form.z;
      let sweep = (pNear.x + pNear.y) * 0.70710678;
      let traced = clamp(presence * 1.7, 0.0, 1.0);
      let front = mix(-0.85, 0.9, traced);
      let strokeMask = smoothstep(0.04, -0.04, sweep - front) * smoothstep(0.0, 0.08, traced);
      let fillMask = smoothstep(0.45, 1.0, presence);
      rim *= strokeMask;
    
      // The face's haze, brighter toward the lamp at the back of the board.
      let hit = origin + dir * tNear;
      let away = normalize(vec2f(dir.x, dir.z) + vec2f(1e-5, 0.0));
      let across = dot((hit - plate.pos.xyz).xz, away);
      // Where it fades, the haze also loses its warmth.
      let fade = max(1.0 + plate.tone.y * scene.sheen.w * across, 0.2);
      let warm = scene.glass.rgb * plate.tone.x * fade * (1.0 + 0.25 * highlight);
      let haze = mix(vec3f(dot(warm, vec3f(0.2126, 0.7152, 0.0722))), warm, pow(min(fade, 1.0), 8.0));
    
      // The lamp's reflection: only a plate tilted toward it catches it.
      var n = quatRotate(plate.rot, vec3f(0.0, 1.0, 0.0));
      if (dot(n, dir) > 0.0) { n = -n; }
      let r = reflect(dir, n);
      let lamp = normalize(scene.light.xyz * 14.0 - hit);
      let facing = dot(r, lamp);
      let fresnel = 0.04 + 0.96 * pow(1.0 - abs(dot(dir, n)), 5.0);
      let glint = scene.sheen.rgb * smoothstep(0.985, 0.997, facing) * fresnel * 6.0;
    
      // A run of aligned tiles glows: the edge and the glass take the accent, and a gleam runs round the outline.
      let glow = plate.tone.w;
      let around = atan2(pNear.y, pNear.x);
      let gleam = pow(0.5 + 0.5 * cos(around - scene.forward.w * 2.4), 6.0);
      let edgeColor = mix(scene.edge.rgb * (1.0 + 0.3 * highlight), scene.accent.rgb * (1.4 + 1.6 * gleam), glow * 0.85);
      // A faint haze stays a sheet of the board, pulled a little toward its edge. A real pigment, such as the mauve tissue, stays that colour.
      let ground = paper(pixel);
      let pigment = smoothstep(0.08, 0.4, max(scene.glass.r, max(scene.glass.g, scene.glass.b)));
      let tint = mix(mix(ground, scene.edge.rgb, 0.14), scene.glass.rgb, pigment);
      let face = mix(haze, tint, sheet);
      let lit = mix(face, scene.accent.rgb * 0.06 * (1.0 + 0.5 * gleam), glow * 0.7) * (1.0 + 0.5 * glow);
      let fillAlpha = mix(scene.glass.w, 0.86, sheet) * cover * fillMask * (1.0 + 0.35 * glow);
      let rimAlpha = min(rim * (1.0 + 0.5 * glow), 1.0);
      var color = edgeColor * rimAlpha + (lit * cover * fillMask + glint * cover * fillMask) * (1.0 - rimAlpha);
      var alpha = rimAlpha + fillAlpha * (1.0 - rimAlpha);
      // The cut. Opaque, a little toward the rim light, so the thickness reads as glass and not as a hole.
      let cap = side * sheet * fillMask;
      let cut = mix(tint, scene.edge.rgb, 0.42) * (1.0 + 0.25 * highlight);
      color = mix(color, cut, cap);
      alpha = mix(alpha, 1.0, cap);
    
      // Opaque: the face we look at takes the lamp (the underside stays in shade), the cut is darker,
      // the far outline is hidden behind the face, and nothing of the board shows through.
      let up = quatRotate(plate.rot, vec3f(0.0, 1.0, 0.0));
      let seenFace = select(-up, up, dot(up, dir) < 0.0);
      let lamp2 = max(dot(seenFace, scene.light.xyz), 0.0);
      let faceLight = select(0.42, 0.58 + 0.42 * lamp2, dot(up, dir) < 0.0);
      let solidFace = mix(slab * faceLight * (1.0 + 0.25 * highlight), scene.accent.rgb * (0.45 + 0.6 * gleam), glow * 0.55) + glint * 0.5;
      let solidSide = slab * 0.62 * (1.0 + 0.25 * highlight);
      let solidBody = mix(solidFace, solidSide, clamp(side, 0.0, 1.0));
      let solidFill = max(cover, side) * fillMask;
      let solidRim = min(max(strokeNear, strokeFar * (1.0 - inNear)) * strokeMask * (1.0 + 0.5 * glow), 1.0) * 0.7;
      let solid = vec4f(edgeColor * solidRim + solidBody * solidFill * (1.0 - solidRim), solidRim + solidFill * (1.0 - solidRim));
    
      let opaque = scene.accent.w;
      let edgeOn = abs(d.y) < 0.2;
      let glass = select(vec4f(color, alpha), profileColor, edgeOn);
      let opaqueOut = select(solid, solidProfile, edgeOn);
      let out = mix(glass, opaqueOut, opaque);
      // Nothing of the plate here: no colour, and no depth to hide what lies behind.
      if (out.a < 0.004) { discard; }
      // Seen from above, the face the ray enters; edge-on, the plate's middle along the ray.
      let t = select(tNear, -dot(o, d), edgeOn);
      return PlateColor(out, clamp(t / DEPTH_SPAN, 0.0, 1.0));
    }
  • guides.wgslvertexfragment Construction lines: every straight edge of every plate, extended across the whole drawing as a dashed hairline, like the guides of a descriptive-geometry épure. The dashes are anchored at the edge, so they slide with its plate. 67 lignes
    Fichier
    src/strates/guides.wgsl
    Points d’entrée
    vs_guide vertex, fs_guide fragment
    Ressources
    1 liaison
    // Construction lines: every straight edge of every plate, extended across the
    // whole drawing as a dashed hairline, like the guides of a descriptive-geometry
    // épure. The dashes are anchored at the edge, so they slide with its plate.
    
    struct Guide {
      point: vec4f,      // a point on the edge (world), w: opacity
      direction: vec4f,  // its direction (world), w: brightness boost
      dashes: vec4f,     // x: phase of the dashes (in periods), y: their period (share of the frame's height)
    };
    @group(0) @binding(1) var<storage, read> guides: array<Guide>;
    
    struct GuideOut {
      @builtin(position) position: vec4f,
      @location(0) along: f32,
      @location(1) across: f32,
      @location(2) @interpolate(flat) opacity: f32,
      @location(3) @interpolate(flat) phase: f32,
      @location(4) @interpolate(flat) period: f32,
    };
    
    fn toPixels(clip: vec4f) -> vec2f {
      let ndc = clip.xy / clip.w;
      return vec2f((ndc.x * 0.5 + 0.5) * scene.viewport.x, (0.5 - ndc.y * 0.5) * scene.viewport.y);
    }
    
    @vertex fn vs_guide(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> GuideOut {
      let guide = guides[instance];
      let a = toPixels(scene.viewProj * vec4f(guide.point.xyz, 1.0));
      let b = toPixels(scene.viewProj * vec4f(guide.point.xyz + guide.direction.xyz, 1.0));
      var along = b - a;
      let span = length(along);
      var out: GuideOut;
      // An edge pointing at the eye has no line to draw.
      let visible = smoothstep(0.02, 0.2, span / max(scene.viewport.w, 1.0));
      along = along / max(span, 1e-6);
      let normal = vec2f(-along.y, along.x);
      let corners = array<vec2f, 6>(vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0), vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0));
      let c = corners[vertex];
      let reach = length(scene.viewport.xy) * 1.5;
      let width = 2.0 * scene.viewport.z;
      let p = a + along * c.x * reach + normal * c.y * width;
      out.position = vec4f(p.x / scene.viewport.x * 2.0 - 1.0, 1.0 - p.y / scene.viewport.y * 2.0, 0.0, 1.0);
      out.along = c.x * reach;
      out.across = c.y * width;
      out.opacity = guide.point.w * visible * (1.0 + guide.direction.w);
      out.phase = guide.dashes.x;
      out.period = guide.dashes.y * scene.viewport.y;
      return out;
    }
    
    @fragment fn fs_guide(in: GuideOut) -> @location(0) vec4f {
      let scale = scene.viewport.z;
      // A one-pixel hairline, antialiased over a pixel: the reference's dashes fall off within a pixel of their core.
      let line = clamp(0.5 * scale + 0.5 - abs(in.across), 0.0, 1.0);
      let period = in.period;
      // Half on, a shade more; the ends soften over a pixel.
      let dash = period * 0.5 + 0.3 * scale;
      // Dashes anchored on the edge, shifted by its phase.
      let u = in.along + dash * 0.5 + in.phase * period;
      let m = u - period * floor(u / period);
      let onDash = smoothstep(-0.6 * scale, 0.6 * scale, min(m, dash - m));
      // Over the darker foot of the board the same ink reads brighter; hold it back a little there.
      let foot = 1.0 - 0.1 * in.position.y / scene.viewport.y;
      let a = line * onDash * scene.guide.w * in.opacity * foot;
      // Half additive: a hairline reads as bright over the glass as over the board.
      return vec4f(scene.guide.rgb * a, a * 0.5);
    }
  • present.wgslfragment Final image: gamma-2.2 encoding (the palette is authored in the same curve), with a fixed grain that also keeps the board's dark ramp from banding. 29 lignes
    Fichier
    src/strates/present.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    2 liaisons
    // Final image: gamma-2.2 encoding (the palette is authored in the same curve),
    // with a fixed grain that also keeps the board's dark ramp from banding.
    
    @group(0) @binding(1) var hdr: texture_2d<f32>;
    @group(0) @binding(2) var clampSampler: sampler;
    
    fn hash(p: vec2f, seed: f32) -> f32 {
      var q = fract(vec3f(p.xyx) * vec3f(0.1031, 0.1030, 0.0973) + seed * 0.1234);
      q += dot(q, q.yzx + 33.33);
      return fract((q.x + q.y) * q.z);
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let image = max(textureSampleLevel(hdr, clampSampler, uv, 0.0).rgb, vec3f(0.0));
      let color = pow(image, vec3f(1.0 / 2.2));
      // The board's tooth, as on the reference print: soft blotches a few pixels wide, about a level deep,
      // a finer grain over them, the odd speck, all fading to half toward the foot of the frame.
      let p = floor(position.xy);
      let cell = p / 4.0;
      let c = floor(cell);
      let f = smoothstep(vec2f(0.0), vec2f(1.0), fract(cell));
      let blotch = mix(mix(hash(c, 1.0), hash(c + vec2f(1.0, 0.0), 1.0), f.x), mix(hash(c + vec2f(0.0, 1.0), 1.0), hash(c + vec2f(1.0, 1.0), 1.0), f.x), f.y) - 0.5;
      let fine = hash(p, 2.0) + hash(p, 6.0) - 1.0;
      let speck = select(0.0, (hash(p, 7.0) - 0.5) * 9.0, hash(p, 8.0) > 0.996);
      let depth = mix(1.0, 0.5, smoothstep(0.45, 0.8, position.y / scene.viewport.y));
      let grain = (blotch * 3.4 + fine * 1.1 + speck) * depth;
      let tint = (vec3f(hash(c, 3.0), hash(c, 4.0), hash(c, 5.0)) - 0.5) * 1.1 * depth;
      return vec4f(color + (vec3f(grain) + tint) / 255.0, 1.0);
    }

Pour les fleurs

WebGPU · WGSL · vgpu

1 shader · 268 lignes

Ouvrir la scène
  • scene.wgslfragment A rainy windowsill, drawn as a picture book: an open sash on the left, three pots on the right, and a watering can between them. Flat colour, round shapes, one fullscreen pass. The visitor moves the can; the rain and the flowers follow. 268 lignes
    Fichier
    src/arrosoir/scene.wgsl
    Points d’entrée
    fs_main fragment
    Ressources
    1 liaison
    // A rainy windowsill, drawn as a picture book: an open sash on the left, three
    // pots on the right, and a watering can between them. Flat colour, round shapes,
    // one fullscreen pass. The visitor moves the can; the rain and the flowers follow.
    
    struct Scene {
      view: vec4f,  // x: time (s), y: aspect, z: motion (0/1), w: half-width of the design space
      can: vec4f,   // x: position, y: water (0..1), z: pouring (0/1), w: rain edge
      pot0: vec4f,  // x: position, y: moisture, z: growth, w: kind (0 daisy, 1 violet, 2 cosmos)
      pot1: vec4f,
      pot2: vec4f,
      extra: vec4f, // xyz: seconds since each flower opened, w: pot radius
    };
    @group(0) @binding(0) var<uniform> scene: Scene;
    
    fn hash11(n: f32) -> f32 {
      return fract(sin(n * 127.1) * 43758.5453);
    }
    
    fn sdBox(p: vec2f, b: vec2f) -> f32 {
      let d = abs(p) - b;
      return length(max(d, vec2f(0.0))) + min(max(d.x, d.y), 0.0);
    }
    
    fn sdRoundBox(p: vec2f, b: vec2f, r: f32) -> f32 {
      let d = abs(p) - b + vec2f(r);
      return length(max(d, vec2f(0.0))) + min(max(d.x, d.y), 0.0) - r;
    }
    
    fn sdSegment(p: vec2f, a: vec2f, b: vec2f) -> f32 {
      let pa = p - a;
      let ba = b - a;
      let h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-5), 0.0, 1.0);
      return length(pa - ba * h);
    }
    
    fn sdEllipse(p: vec2f, r: vec2f) -> f32 {
      let k = p / max(r, vec2f(1e-4));
      return (length(k) - 1.0) * min(r.x, r.y);
    }
    
    fn rot(p: vec2f, a: f32) -> vec2f {
      let c = cos(a);
      let s = sin(a);
      return vec2f(c * p.x - s * p.y, s * p.x + c * p.y);
    }
    
    fn over(dst: vec3f, src: vec3f, d: f32, px: f32) -> vec3f {
      return mix(dst, src, smoothstep(px, -px, d));
    }
    
    fn shade(base: vec3f, light: f32) -> vec3f {
      return base * (0.78 + 0.28 * light) + vec3f(0.08, 0.07, 0.06) * light * light;
    }
    
    fn petalColor(kind: f32) -> vec3f {
      if (kind < 0.5) { return vec3f(0.98, 0.96, 0.91); }
      if (kind < 1.5) { return vec3f(0.64, 0.46, 0.84); }
      return vec3f(0.95, 0.48, 0.5);
    }
    
    fn centerColor(kind: f32) -> vec3f {
      if (kind < 0.5) { return vec3f(0.95, 0.72, 0.22); }
      if (kind < 1.5) { return vec3f(0.98, 0.9, 0.45); }
      return vec3f(0.98, 0.78, 0.28);
    }
    
    fn petalCount(kind: f32) -> f32 {
      return select(8.0, 5.0, kind > 0.5 && kind < 1.5);
    }
    
    /** Petals as ellipses around a point. `open` is 0 (a bud) to 1 (spread). */
    fn petals(p: vec2f, n: f32, radius: f32, size: f32) -> f32 {
      let ang = atan2(p.y, p.x);
      let sector = 6.2831853 / n;
      let a = (fract(ang / sector + 0.5) - 0.5) * sector;
      let local = vec2f(cos(a), sin(a)) * length(p) - vec2f(radius, 0.0);
      let e = local / vec2f(size, size * 0.62);
      return length(e) - 1.0;
    }
    
    fn plant(color: vec3f, q: vec2f, origin: vec2f, moisture: f32, growth: f32, kind: f32, age: f32, motion: f32, px: f32) -> vec3f {
      var c = color;
      let g = clamp(growth, 0.0, 1.0);
      let droop = (1.0 - smoothstep(0.02, 0.22, moisture)) * smoothstep(0.14, 0.2, g) * (1.0 - smoothstep(0.88, 0.94, g));
      let sway = sin(scene.view.x * 1.4 + origin.x * 9.0) * 0.01 * motion * smoothstep(0.1, 0.4, g);
      let base = origin;
      let stemH = (0.04 + 0.24 * smoothstep(0.12, 0.8, g)) * (1.0 - 0.38 * droop);
      let top = base + vec2f(sway * stemH * 8.0, stemH);
      // A seed, until the stem clears the soil.
      if (g < 0.18) {
        let seed = sdEllipse(q - base - vec2f(0.0, 0.012), vec2f(0.012, 0.008) * (1.0 - g / 0.18));
        c = over(c, vec3f(0.42, 0.26, 0.14), seed, px);
      }
      if (g > 0.1) {
        let stem = sdSegment(q, base, top) - 0.007;
        c = over(c, shade(vec3f(0.28, 0.52, 0.3), 0.6), stem, px);
        let leafOpen = smoothstep(0.14, 0.42, g);
        let leafY = mix(base.y, top.y, 0.42);
        let leafP = q - vec2f(base.x + sway * 0.3, leafY);
        let leafL = sdEllipse(leafP - vec2f(-0.03 * leafOpen, 0.0), vec2f(0.034, 0.014) * leafOpen);
        let leafR = sdEllipse(vec2f(-leafP.x, leafP.y) - vec2f(-0.03 * leafOpen, 0.004), vec2f(0.03, 0.012) * leafOpen);
        c = over(c, vec3f(0.42, 0.68, 0.36), leafL, px);
        c = over(c, vec3f(0.33, 0.58, 0.32), leafR, px);
      }
      let bud = smoothstep(0.5, 0.66, g) * (1.0 - smoothstep(0.78, 0.9, g));
      if (bud > 0.02) {
        let budD = length(q - top) - 0.018 * bud;
        c = over(c, petalColor(kind) * 0.75, budD, px);
      }
      let open = smoothstep(0.8, 0.96, g);
      if (open > 0.02) {
        let bounce = 1.0 + motion * 0.07 * sin(min(age, 2.0) * 14.0) * exp(-age * 2.4);
        let spread = (0.02 + 0.034 * open) * bounce * select(1.0, 0.82, kind > 0.5 && kind < 1.5);
        let flower = petals(q - top, petalCount(kind), spread, spread * 0.85);
        c = over(c, petalColor(kind), flower, px);
        let eye = length(q - top) - spread * 0.55;
        c = over(c, centerColor(kind), eye, px);
      }
      return c;
    }
    
    fn pot(color: vec3f, q: vec2f, x: f32, moisture: f32, radius: f32, px: f32) -> vec3f {
      var c = color;
      // The rim sits just above the sill, so the pot reads as standing on the wood.
      let o = vec2f(x, -0.1);
      let p = q - o;
      let body = sdRoundBox(p - vec2f(0.0, -0.02), vec2f(radius * 0.78, radius * 0.62), radius * 0.34);
      let shadow = sdEllipse(q - vec2f(x, -0.28), vec2f(radius * 1.15, radius * 0.18));
      c = over(c, vec3f(0.45, 0.32, 0.22) * 0.55, shadow, px * 2.0);
      let clay = mix(vec3f(0.78, 0.42, 0.32), vec3f(0.86, 0.5, 0.38), clamp(p.x / radius * 0.5 + 0.5, 0.0, 1.0));
      c = over(c, shade(clay, smoothstep(0.2, -0.6, p.x / radius)), body, px);
      // A pale band, so the three pots read as a set of toys.
      let band = sdRoundBox(p - vec2f(0.0, -0.05), vec2f(radius * 0.82, radius * 0.08), radius * 0.08);
      c = over(c, clay * 1.15, max(band, body), px);
      let rim = sdRoundBox(p - vec2f(0.0, radius * 0.5), vec2f(radius * 0.98, radius * 0.1), radius * 0.09);
      c = over(c, shade(clay * 1.05, 0.7), rim, px);
      let wet = clamp(moisture, 0.0, 1.0);
      let soilColor = mix(vec3f(0.45, 0.3, 0.18), vec3f(0.24, 0.14, 0.08), wet);
      let soil = sdEllipse(p - vec2f(0.0, radius * 0.46), vec2f(radius * 0.78, radius * 0.16));
      c = over(c, soilColor, max(soil, -rim), px);
      if (moisture > 1.0) {
        let puddle = sdEllipse(p - vec2f(0.0, radius * 0.5), vec2f(radius * 0.36, radius * 0.07) * smoothstep(1.0, 1.12, moisture));
        c = over(c, vec3f(0.45, 0.62, 0.78), puddle, px);
      }
      return c;
    }
    
    fn wateringCan(color: vec3f, q: vec2f, px: f32) -> vec3f {
      var c = color;
      let tilt = scene.can.z * -0.7;
      let origin = vec2f(scene.can.x, 0.02);
      let p = rot(q - origin, -tilt);
      let body = sdRoundBox(p - vec2f(-0.01, 0.0), vec2f(0.07, 0.05), 0.03);
      let shadow = sdEllipse(q - origin - vec2f(0.0, -0.16), vec2f(0.1, 0.025));
      c = over(c, vec3f(0.55, 0.62, 0.7) * 0.35, shadow, px * 2.0);
      c = over(c, shade(vec3f(0.55, 0.72, 0.78), smoothstep(0.2, -0.5, p.x)), body, px);
      let waterH = mix(-0.045, 0.03, scene.can.y);
      let water = max(body, p.y - waterH);
      c = over(c, vec3f(0.38, 0.62, 0.82), water, px);
      let spout = sdSegment(p, vec2f(0.05, 0.02), vec2f(0.15, 0.045)) - 0.012;
      let rose = sdEllipse(p - vec2f(0.155, 0.03), vec2f(0.02, 0.016));
      c = over(c, vec3f(0.48, 0.66, 0.74), spout, px);
      c = over(c, vec3f(0.42, 0.58, 0.66), rose, px);
      let handle = sdSegment(p, vec2f(-0.06, 0.02), vec2f(-0.11, 0.045)) - 0.01;
      let handle2 = sdSegment(p, vec2f(-0.11, 0.045), vec2f(-0.07, -0.02)) - 0.01;
      c = over(c, vec3f(0.45, 0.62, 0.7), min(handle, handle2), px);
      // A tiny leaf on the belly, so the can belongs to the pots.
      let mark = sdEllipse(p - vec2f(-0.01, 0.01), vec2f(0.016, 0.008));
      c = over(c, vec3f(0.45, 0.7, 0.42), max(mark, -body + 0.004), px);
      return c;
    }
    
    fn pour(color: vec3f, q: vec2f, pots: array<vec4f, 3>, px: f32) -> vec3f {
      if (scene.can.z < 0.5 || scene.can.y < 0.01) { return color; }
      var c = color;
      // Same tilt as the can: the shape turns clockwise, so the spout dips toward the soil.
      var spout = vec2f(scene.can.x, 0.02) + rot(vec2f(0.15, 0.04), scene.can.z * -0.7);
      var soil = spout + vec2f(0.02, -0.2);
      var best = 1e3;
      for (var i = 0u; i < 3u; i++) {
        let d = abs(pots[i].x - scene.can.x);
        if (d < best) {
          best = d;
          soil = vec2f(pots[i].x, -0.05);
        }
      }
      let wobble = sin(scene.view.x * 18.0 * scene.view.z + q.y * 40.0) * 0.004 * scene.view.z;
      let stream = sdSegment(q - vec2f(wobble, 0.0), spout, soil) - 0.008;
      c = over(c, vec3f(0.55, 0.75, 0.92), stream, px);
      let splash = length(q - soil) - 0.02 - 0.008 * sin(scene.view.x * 12.0 * scene.view.z);
      c = over(c, vec3f(0.7, 0.84, 0.95), splash, px);
      return c;
    }
    
    fn rain(q: vec2f, half: f32, px: f32) -> vec3f {
      var add = vec3f(0.0);
      let t = scene.view.x * scene.view.z;
      for (var i = 0; i < 36; i++) {
        let fi = f32(i);
        let column = hash11(fi * 3.1);
        // Most drops fall through the open sash; a few streak the glass above the pots.
        let onSill = column < 0.72;
        let x = select(mix(scene.can.w, half, hash11(fi + 1.7)), mix(-half, scene.can.w + 0.04, hash11(fi + 2.2)), onSill);
        let speed = 0.45 + hash11(fi + 4.0) * 0.55;
        let span = select(0.55, 0.85, onSill);
        let y = fract(hash11(fi + 8.0) - t * speed) * span + select(0.05, -0.42, onSill);
        if (y > 0.5 || abs(x) > half * 0.9) { continue; }
        if (!onSill && y < -0.02) { continue; }
        let drop = q - vec2f(x, y);
        let streak = sdSegment(drop, vec2f(0.0, 0.0), vec2f(-0.012, select(-0.045, -0.07, onSill))) - 0.0035;
        let glint = smoothstep(px * 1.4, -px, streak);
        add += vec3f(0.82, 0.88, 0.98) * glint * select(0.45, 0.7, onSill);
      }
      return add;
    }
    
    @fragment fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let aspect = max(scene.view.y, 0.35);
      let half = max(scene.view.w, 0.2);
      let q = vec2f((uv.x - 0.5) * aspect, 0.5 - uv.y) * (half / max(0.5 * aspect, 1e-4));
      let px = fwidth(q.x) * 0.75;
      let time = scene.view.x;
    
      // The garden outside, then the warm room around the open window.
      let sky = mix(vec3f(0.62, 0.74, 0.86), vec3f(0.86, 0.9, 0.93), smoothstep(0.35, -0.05, q.y));
      var color = sky;
      let cloud = sdEllipse(q - vec2f(-0.25 + sin(time * 0.05) * 0.02 * scene.view.z, 0.28), vec2f(0.16, 0.05));
      let cloud2 = sdEllipse(q - vec2f(0.22, 0.34), vec2f(0.2, 0.045));
      color = over(color, vec3f(0.93, 0.95, 0.97), cloud, px * 3.0);
      color = over(color, vec3f(0.9, 0.93, 0.96), cloud2, px * 3.0);
      let hill = q.y - (-0.02 + 0.03 * sin(q.x * 3.0));
      color = over(color, vec3f(0.62, 0.74, 0.58), hill, px);
      let hill2 = q.y - (-0.08 + 0.02 * sin(q.x * 5.0 + 1.0));
      color = over(color, vec3f(0.5, 0.66, 0.48), hill2, px);
      let opening = sdBox(q - vec2f(0.0, 0.12), vec2f(half * 0.94, 0.42));
      let wall = vec3f(0.95, 0.9, 0.84);
      color = mix(wall, color, smoothstep(px, -px, opening));
      let frame = abs(opening) - 0.025;
      color = over(color, vec3f(0.72, 0.52, 0.36), frame - 0.02 * step(0.0, opening), px);
    
      // The wooden sill, wet where the sash is open.
      let sill = sdRoundBox(q - vec2f(0.0, -0.28), vec2f(half * 0.98, 0.14), 0.04);
      let wood = mix(vec3f(0.72, 0.5, 0.32), vec3f(0.8, 0.58, 0.36), 0.5 + 0.5 * sin(q.x * 28.0));
      color = over(color, wood, sill, px);
      let wetWood = max(sill, q.x - scene.can.w);
      color = over(color, wood * 0.72, wetWood, px);
    
      // A soft reminder of where the rain is allowed to fall.
      let zone = max(sill, q.x - (scene.can.w - 0.01));
      color = mix(color, color + vec3f(0.04, 0.05, 0.07), smoothstep(px * 4.0, -px, zone) * 0.35);
      color += rain(q, half, px);
    
      let radius = scene.extra.w;
      let pots = array<vec4f, 3>(scene.pot0, scene.pot1, scene.pot2);
      let ages = array<f32, 3>(scene.extra.x, scene.extra.y, scene.extra.z);
      for (var i = 0u; i < 3u; i++) {
        color = pot(color, q, pots[i].x, pots[i].y, radius, px);
        let soil = vec2f(pots[i].x, -0.1 + radius * 0.55);
        color = plant(color, q, soil, pots[i].y, pots[i].z, pots[i].w, ages[i], scene.view.z, px);
      }
      color = wateringCan(color, q, px);
      color = pour(color, q, pots, px);
    
      let vignette = smoothstep(0.2, 0.95, length(uv - 0.5));
      color *= 1.0 - vignette * 0.18;
      let grain = hash11(position.x * 0.17 + position.y * 0.13 + fract(time) * 17.0) - 0.5;
      return vec4f(clamp(color + grain / 255.0, vec3f(0.0), vec3f(1.0)), 1.0);
    }

Prototypes

Des essais jetables, absents du site : seuls leurs shaders sont montrés ici.

La mémoire du sable

WebGPU · WGSL · vgpu

11 shaders · 1 618 lignes

Hors du site, dans dune/prototype-rendu/

  • common.wgslmodule « La mémoire du sable ». Shared by every shader of the prototype: the scene, hashes and noise, the shape of the dunes and the atmosphere's media. World frame: metres, y up, the observer near the origin looking toward +z. The observer's ground is at y ≈ 0, the desert floor 14 m below. 404 lignes
    Fichier
    dune/prototype-rendu/shaders/common.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    1 liaison
    // PROTOTYPE jetable — « La mémoire du sable ».
    // Shared by every shader of the prototype: the scene, hashes and noise, the shape of the
    // dunes and the atmosphere's media. World frame: metres, y up, the observer near the origin
    // looking toward +z. The observer's ground is at y ≈ 0, the desert floor 14 m below.
    
    struct Scene {
      view: mat4x4f,
      proj: mat4x4f,
      viewProj: mat4x4f,
      invViewProj: mat4x4f,
      eye: vec4f,        // xyz: observer (m), w: clock (s)
      sun: vec4f,        // xyz: unit direction toward the sun, w: elevation (rad)
      sunLight: vec4f,   // x: sun irradiance above the atmosphere, y: how much lower the sky's sun is (rad), z: sky grade (0..1.5), w: unused
      wind: vec4f,       // xy: wind over the crest (x, z), z: its speed (m/s), w: ripple wind angle (rad)
      lens: vec4f,       // x: focus distance (m), y: circle of confusion at infinity (px), z: tan(vfov / 2), w: aspect
      viewport: vec4f,   // width, height (px), 1 / width, 1 / height
      look: vec4f,       // x: exposure (linear), y: view mode, z: ripple strength, w: glitter strength
      grid: vec4f,       // x: grid yaw (rad), y: grid half angle (rad), z: inner radius (m), w: outer radius (m)
      haze: vec4f,       // x: dust density, y: spray amount, z: compare split (0..1), w: cloud cover
      sim: vec4f,        // x: step (s), y: grain count, z: frame index, w: trace shown (0/1)
      shadowNear: vec4f, // x0, z0 (m), size (m), texels
      shadowFar: vec4f,
      trail: vec4f,      // the sand one can draw in: x0, z0 (m), size (m), texels
    };
    @group(0) @binding(0) var<uniform> scene: Scene;
    
    const PI: f32 = 3.14159265;
    const TAU: f32 = 6.28318531;
    
    // View modes, in the order of the prototype's switcher.
    const VIEW_FINAL: i32 = 0;
    const VIEW_SHAPE: i32 = 1;
    const VIEW_RIPPLES: i32 = 2;
    const VIEW_GLITTER: i32 = 3;
    const VIEW_SHADOWS: i32 = 4;
    const VIEW_DEPTH: i32 = 5;
    const VIEW_SPRAY: i32 = 6;
    const VIEW_COMPARE: i32 = 7;
    fn viewMode() -> i32 { return i32(scene.look.y + 0.5); }
    
    // ---------------------------------------------------------------- hashes and noise
    
    fn pcg(v: u32) -> u32 {
      let state = v * 747796405u + 2891336453u;
      let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
      return (word >> 22u) ^ word;
    }
    fn rand(seed: u32) -> f32 { return f32(pcg(seed)) / 4294967296.0; }
    
    fn hash2(p: vec2f) -> f32 {
      var q = fract(vec3f(p.x, p.y, p.x) * 0.1031);
      q += dot(q, q.yzx + 33.33);
      return fract((q.x + q.y) * q.z);
    }
    fn hash22(p: vec2f) -> vec2f {
      var q = fract(vec3f(p.x, p.y, p.x) * vec3f(0.1031, 0.1030, 0.0973));
      q += dot(q, q.yzx + 33.33);
      return fract((q.xx + q.yz) * q.zy);
    }
    fn hash3(p: vec3f) -> f32 {
      var q = fract(p * 0.1031);
      q += dot(q, q.zyx + 31.32);
      return fract((q.x + q.y) * q.z);
    }
    
    /** Value noise in [0, 1]. */
    fn noise(p: vec2f) -> f32 {
      let i = floor(p);
      let f = fract(p);
      let u = f * f * (3.0 - 2.0 * f);
      return mix(mix(hash2(i), hash2(i + vec2f(1.0, 0.0)), u.x), mix(hash2(i + vec2f(0.0, 1.0)), hash2(i + vec2f(1.0, 1.0)), u.x), u.y);
    }
    
    /** Value noise with its gradient (Inigo Quilez): x = value in [0, 1], yz = d/dp. */
    fn noised(p: vec2f) -> vec3f {
      let i = floor(p);
      let f = fract(p);
      let u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
      let du = 30.0 * f * f * (f * (f - 2.0) + 1.0);
      let a = hash2(i);
      let b = hash2(i + vec2f(1.0, 0.0));
      let c = hash2(i + vec2f(0.0, 1.0));
      let d = hash2(i + vec2f(1.0, 1.0));
      let k1 = b - a;
      let k2 = c - a;
      let k4 = a - b - c + d;
      return vec3f(a + k1 * u.x + k2 * u.y + k4 * u.x * u.y, du * vec2f(k1 + k4 * u.y, k2 + k4 * u.x));
    }
    
    /** 3D value noise with its gradient: x = value, yzw = d/dp. */
    fn noised3(x: vec3f) -> vec4f {
      let i = floor(x);
      let w = fract(x);
      let u = w * w * w * (w * (w * 6.0 - 15.0) + 10.0);
      let du = 30.0 * w * w * (w * (w - 2.0) + 1.0);
      let a = hash3(i);
      let b = hash3(i + vec3f(1.0, 0.0, 0.0));
      let c = hash3(i + vec3f(0.0, 1.0, 0.0));
      let d = hash3(i + vec3f(1.0, 1.0, 0.0));
      let e = hash3(i + vec3f(0.0, 0.0, 1.0));
      let f = hash3(i + vec3f(1.0, 0.0, 1.0));
      let g = hash3(i + vec3f(0.0, 1.0, 1.0));
      let h = hash3(i + vec3f(1.0, 1.0, 1.0));
      let k1 = b - a;
      let k2 = c - a;
      let k3 = e - a;
      let k4 = a - b - c + d;
      let k5 = a - c - e + g;
      let k6 = a - b - e + f;
      let k7 = -a + b + c - d + e - f - g + h;
      return vec4f(
        a + k1 * u.x + k2 * u.y + k3 * u.z + k4 * u.x * u.y + k5 * u.y * u.z + k6 * u.z * u.x + k7 * u.x * u.y * u.z,
        du * vec3f(
          k1 + k4 * u.y + k6 * u.z + k7 * u.y * u.z,
          k2 + k5 * u.z + k4 * u.x + k7 * u.z * u.x,
          k3 + k6 * u.x + k5 * u.y + k7 * u.x * u.y));
    }
    
    fn fbm(p: vec2f, octaves: i32) -> f32 {
      var sum = 0.0;
      var amplitude = 0.5;
      var q = p;
      for (var i = 0; i < octaves; i++) {
        sum += noise(q) * amplitude;
        q = mat2x2f(1.6, 1.2, -1.2, 1.6) * q + vec2f(3.1, 1.7);
        amplitude *= 0.5;
      }
      return sum;
    }
    
    fn smin(a: f32, b: f32, k: f32) -> f32 {
      let h = max(k - abs(a - b), 0.0) / k;
      return min(a, b) - h * h * k * 0.25;
    }
    fn smax(a: f32, b: f32, k: f32) -> f32 { return -smin(-a, -b, k); }
    
    // ---------------------------------------------------------------- the dunes
    
    /**
     * The hero dune, as in the concept. A sharp ridge rises from the sand sheet at the observer's
     * right and climbs, ever more prominent, to a pointed summit 48 m ahead at eye level. Its left
     * face takes the low sun; its right face, a gentle 12° slope, is in the ridge's own shade and
     * shows beyond it. From the summit a second arm leaves toward the right and away, its slip face
     * looking back at the observer across the hollow between the two. To the left the dune's back
     * rolls off toward the desert floor, drawing the skyline that runs down from the summit.
     */
    /** The erg's floor, well below the observer: they look down on the sea of dunes. */
    const FLOOR: f32 = -45.0;
    /** Angle of repose of dry sand, 33°. */
    const SLIP_SLOPE: f32 = 0.65;
    /** Downwind, across the ridge (the observer's right). */
    const CREST_ACROSS = vec2f(0.99939, 0.0349);
    const SUMMIT_Z: f32 = 48.0;
    /** The far arm leaves the summit along FAR_ALONG; its slip face looks toward FAR_LEE. */
    const FAR_ALONG = vec2f(0.9578, 0.2873);
    const FAR_LEE = vec2f(0.2873, -0.9578);
    
    /** Where the ridge runs, x metres right of the observer, z metres ahead. */
    fn nearBrink(z: f32) -> f32 {
      return 3.2 * exp(-z / 18.0) + 0.1 * sin(z * 0.11 + 0.6);
    }
    /** Height of the ridge: from the sand sheet to the summit, where it ends. */
    fn nearHeight(z: f32) -> f32 {
      return -0.3 + 1.85 * smoothstep(0.0, SUMMIT_Z, z) - 22.0 * smoothstep(SUMMIT_Z + 1.0, SUMMIT_Z + 26.0, z) - 5.0 * (1.0 - smoothstep(-120.0, -20.0, z));
    }
    /** How far the ridge stands out of the sheet: barely at the observer's feet, fully at the summit. */
    fn nearProminence(z: f32) -> f32 {
      return 0.1 + 0.9 * smoothstep(0.0, 36.0, z);
    }
    fn summit() -> vec2f { return vec2f(nearBrink(SUMMIT_Z), SUMMIT_Z); }
    /** Height of the far arm's brink, u metres from the summit: it runs down toward its horn. */
    fn farHeight(u: f32) -> f32 {
      let v = max(u, 0.0);
      return nearHeight(SUMMIT_Z) - 0.1 * v - 0.0012 * v * v - 30.0 * (1.0 - smoothstep(-25.0, 0.0, u));
    }
    
    /** s: metres across the crest toward its lee; u: along it; prominence scales both flanks. */
    struct Crest { s: f32, u: f32, height: f32, prominence: f32, lee: f32 };
    
    fn nearFrame(p: vec2f) -> Crest {
      return Crest(p.x - nearBrink(p.y), p.y, nearHeight(p.y), nearProminence(p.y), 0.22);
    }
    fn farFrame(p: vec2f) -> Crest {
      let q = p - summit();
      let u = dot(q, FAR_ALONG);
      return Crest(dot(q, FAR_LEE), u, farHeight(u), 1.0, SLIP_SLOPE);
    }
    
    /** One arm: a convex windward back (~8° before the crest, steeper down) and its lee face. */
    fn armDune(c: Crest) -> f32 {
      let span = 9.0;
      let slope = 0.4;
      let lead = 5.0;
      let d = lead - c.s;
      let back = c.height - c.prominence * span * slope * (sqrt(1.0 + d * d / (span * span)) - sqrt(1.0 + lead * lead / (span * span)));
      // The lee steepens as it goes down; a slip face stays at the angle of repose.
      let down = max(c.s, 0.0);
      let lee = c.height - c.prominence * (c.s * c.lee + select(0.012 * down * down, 0.0, c.lee >= SLIP_SLOPE));
      return smin(back, lee, 0.14);
    }
    
    fn heroDune(p: vec2f) -> f32 {
      return smax(armDune(nearFrame(p)), armDune(farFrame(p)), 1.0);
    }
    
    /** The arm that shapes the sand at p (for the wind and the blown sand). */
    fn crestFrame(p: vec2f) -> Crest {
      let near = nearFrame(p);
      let far = farFrame(p);
      if (armDune(far) > armDune(near)) { return far; }
      return near;
    }
    
    /** A point on the near arm, u metres ahead and s metres downwind of its brink. */
    fn crestPoint(u: f32, s: f32) -> vec2f {
      return vec2f(nearBrink(u) + s, u);
    }
    /** A point on the far arm, u metres from the summit and s metres toward the hollow. */
    fn farPoint(u: f32, s: f32) -> vec2f {
      return summit() + FAR_ALONG * u + FAR_LEE * s;
    }
    
    /**
     * A family of rounded dunes, as the far erg reads at dusk: a gentle back rising to a soft
     * crest and a shorter, steeper lee, with sinuous crests whose height varies along them.
     */
    fn softRow(p: vec2f, wind: vec2f, wavelength: f32, amplitude: f32, seed: f32) -> f32 {
      let across = vec2f(-wind.y, wind.x);
      let a = dot(p, across);
      let bend = (noise(vec2f(a / (wavelength * 2.1), seed)) - 0.5) * wavelength * 1.5
        + (noise(p / (wavelength * 2.9) + seed) - 0.5) * wavelength * 1.2;
      let cell = (dot(p, wind) + bend) / wavelength;
      let row = floor(cell);
      let x = cell - row;
      // Heights vary along each crest: humps and saddles, as the far skylines show.
      let local = amplitude * smoothstep(0.08, 0.75, noise(vec2f(a / (wavelength * 0.9) + row * 3.7, row * 1.37 + seed)))
        * (0.5 + 0.9 * noise(vec2f(a / (wavelength * 0.45) + seed * 2.3, row * 2.1 + 4.0)));
      // Rise over 68% of the period, fall over the rest, rounded at the top and in the trough.
      let crest = 0.68;
      let rise = 0.5 - 0.5 * cos(PI * clamp(x / crest, 0.0, 1.0));
      let fall = 0.5 + 0.5 * cos(PI * clamp((x - crest) / (1.0 - crest), 0.0, 1.0));
      let shape = select(fall, rise, x < crest);
      return local * pow(shape, 1.25);
    }
    
    /**
     * The erg, as in the concept: a sea of rounded dunes of three sizes and headings, crossing
     * into star-like knots, on long swells that stack their skylines one behind the other toward
     * the horizon. Kept low around the hero dune, so it stands on its own above them.
     */
    fn duneField(p: vec2f) -> f32 {
      let reach = length(p - vec2f(0.0, 40.0));
      let open = smoothstep(80.0, 420.0, reach);
      // The dunes grow a little with distance, so the far rows still read as crests in the haze.
      let far = 1.0 + 0.3 * smoothstep(600.0, 3500.0, reach);
      let swell = (fbm(p / 1300.0 + 11.0, 3) - 0.45) * 44.0 + (fbm(p / 520.0 + 5.0, 2) - 0.5) * 10.0;
      // The big rows run across the view, so their crests stack into skylines toward the
      // horizon, each one's shaded face toward the observer with the low sun rimming its top.
      let a = softRow(p, vec2f(0.3, 0.954), 150.0, 13.0 * far, 1.7);
      let b = softRow(p + vec2f(37.0, 91.0), vec2f(0.8944, 0.4472), 85.0, 7.0 * far, 5.3);
      let c = softRow(p + vec2f(-211.0, 57.0), vec2f(-0.35, 0.937), 230.0, 17.0 * far, 8.1);
      let knots = max(max(a, b), c) + 0.3 * min(a, b);
      return FLOOR + swell * open + knots * (0.3 + 0.7 * open);
    }
    
    /** Ridges far away on the horizon, 7 to 25 km out. */
    fn mountains(p: vec2f) -> f32 {
      let r = length(p);
      if (r < 6500.0) { return 0.0; }
      var q = p / 4200.0;
      var sum = 0.0;
      var amplitude = 0.55;
      for (var i = 0; i < 5; i++) {
        let n = 1.0 - abs(noise(q) * 2.0 - 1.0);
        sum += n * n * amplitude;
        q = mat2x2f(1.6, 1.2, -1.2, 1.6) * q + vec2f(1.7, 9.2);
        amplitude *= 0.5;
      }
      return smoothstep(7000.0, 16000.0, r) * max(sum - 0.22, 0.0) * 950.0;
    }
    
    fn terrainHeight(p: vec2f) -> f32 {
      return smax(heroDune(p), duneField(p), 3.0) + mountains(p);
    }
    
    fn terrainNormal(p: vec2f, e: f32) -> vec3f {
      let hx = terrainHeight(p + vec2f(e, 0.0)) - terrainHeight(p - vec2f(e, 0.0));
      let hz = terrainHeight(p + vec2f(0.0, e)) - terrainHeight(p - vec2f(0.0, e));
      return normalize(vec3f(-hx, 2.0 * e, -hz));
    }
    
    // ---------------------------------------------------------------- atmosphere
    
    const EARTH_RADIUS: f32 = 6360000.0;
    const ATMOSPHERE_TOP: f32 = 100000.0;
    /** The desert floor, metres above sea level. */
    const GROUND_ALTITUDE: f32 = 320.0;
    const RAYLEIGH_SCATTER = vec3f(5.802e-6, 13.558e-6, 33.1e-6);
    const MIE_SCATTER: f32 = 3.996e-6;
    const MIE_EXTINCTION: f32 = 4.44e-6;
    const OZONE_ABSORB = vec3f(0.650e-6, 1.881e-6, 0.085e-6) * 1.8;
    /** Desert dust: a low haze, slightly absorbing in the blue, so it glows warm toward the sun. */
    const DUST_SCATTER = vec3f(0.95e-4, 0.97e-4, 1.0e-4);
    const DUST_ABSORB = vec3f(0.35e-5, 0.7e-5, 1.5e-5);
    const DUST_HEIGHT: f32 = 380.0;
    /**
     * Stand-in for multiple scattering: an isotropic share of the light the medium receives. At
     * dusk most of it comes from high air still in bluer sunlight, so it uses the sun's
     * transmittance from at least MULTIPLE_SCATTERING_ALTITUDE.
     */
    const MULTIPLE_SCATTERING: f32 = 0.018;
    const MULTIPLE_SCATTERING_ALTITUDE: f32 = 9000.0;
    
    /**
     * Art direction over physics: the concept's sand reads golden and saturated, lit mostly by
     * the sun, so the sun is pushed and the sky's fill held back on surfaces (not in the air).
     */
    const SUN_ON_SAND: f32 = 1.6;
    /**
     * The concept's erg is deep in haze a few hundred metres out: the view rays see the low dust
     * this much denser than the sun's rays do (which would otherwise redden the light to nothing).
     */
    const HAZE_ON_VIEW: f32 = 1.5;
    /** The haze over the erg reads mauve in the concept, not orange: its light is tinted so. */
    const HAZE_TINT = vec3f(0.86, 0.8, 1.0);
    const SKY_ON_SAND: f32 = 0.7;
    
    /**
     * The sun the sky and the air are computed with sits a little lower than the one lighting the
     * sand: the concept's saturated dusk (apricot band, deep blue overhead) with sand that stays
     * golden instead of turning blood red. Art direction, like SUN_ON_SAND.
     */
    fn skySunElevation() -> f32 { return scene.sun.w - scene.sunLight.y; }
    fn skySun() -> vec3f {
      let e = skySunElevation();
      let h = normalize(scene.sun.xz + vec2f(1e-5, 0.0));
      return vec3f(h.x * cos(e), sin(e), h.y * cos(e));
    }
    
    /**
     * Grading of the sky, as a photographer would in post: its hue slides toward the concept's
     * slate blue as it rises, and warms to apricot near the horizon on the sun's side. The
     * horizon itself is left alone, so the far dunes' haze still meets the sky without a seam.
     * The sky's light on the sand is graded the same way (irradiance table), so shadows turn mauve.
     */
    const SLATE = vec3f(0.05, 0.1, 0.24);
    fn skyGrade(dir: vec3f, color: vec3f) -> vec3f {
      let amount = scene.sunLight.z;
      let up = smoothstep(0.03, 0.4, dir.y);
      let sunward = pow(max(dot(normalize(dir.xz + vec2f(1e-5, 0.0)), normalize(scene.sun.xz + vec2f(1e-5, 0.0))), 0.0), 2.0);
      let band = (1.0 - smoothstep(0.0, 0.16, dir.y)) * smoothstep(-0.005, 0.01, dir.y);
      let luma = dot(color, vec3f(0.2126, 0.7152, 0.0722));
      let slate = SLATE * (luma / dot(SLATE, vec3f(0.2126, 0.7152, 0.0722)));
      var graded = mix(color, slate, clamp(up * amount * 0.88, 0.0, 1.0)) * mix(1.0, 0.8, up * amount);
      graded *= mix(vec3f(1.0), vec3f(1.5, 1.05, 0.62), band * (0.3 + 0.7 * sunward) * amount);
      return graded;
    }
    
    /** World height (m) to altitude above sea level. */
    fn altitudeOf(y: f32) -> f32 { return GROUND_ALTITUDE - FLOOR + y; }
    
    struct Media { rayleigh: vec3f, mie: f32, dust: vec3f, extinction: vec3f };
    
    fn media(altitude: f32) -> Media {
      let h = max(altitude, 0.0);
      let rayleigh = exp(-h / 8000.0);
      let mie = exp(-h / 1200.0);
      let ozone = max(0.0, 1.0 - abs(h - 25000.0) / 15000.0);
      let dust = exp(-max(h - GROUND_ALTITUDE, 0.0) / DUST_HEIGHT) * scene.haze.x;
      let sR = RAYLEIGH_SCATTER * rayleigh;
      let sD = DUST_SCATTER * dust;
      return Media(sR, MIE_SCATTER * mie, sD, sR + vec3f(MIE_EXTINCTION * mie) + OZONE_ABSORB * ozone + sD + DUST_ABSORB * dust);
    }
    
    fn phaseRayleigh(nu: f32) -> f32 { return 3.0 / (16.0 * PI) * (1.0 + nu * nu); }
    fn phaseHG(nu: f32, g: f32) -> f32 {
      let d = max(1.0 + g * g - 2.0 * g * nu, 1e-4);
      return (1.0 - g * g) / (4.0 * PI * d * sqrt(d));
    }
    fn phaseDust(nu: f32) -> f32 { return 0.75 * phaseHG(nu, 0.6) + 0.25 * phaseHG(nu, -0.2); }
    
    /** Transmittance lookup coordinates: cosine of the zenith angle, then altitude. */
    fn transmittanceUv(altitude: f32, mu: f32) -> vec2f {
      return vec2f(sqrt(clamp((mu + 0.25) / 1.25, 0.0, 1.0)), sqrt(clamp(altitude / ATMOSPHERE_TOP, 0.0, 1.0)));
    }
    
    /** Sky-view lookup: azimuth away from the sun (0..π) and elevation, dense near the horizon. */
    const SKY_LOW: f32 = -0.12;
    fn skyViewUv(dir: vec3f) -> vec2f {
      let horizontal = length(dir.xz);
      let sunHorizontal = normalize(scene.sun.xz + vec2f(1e-5, 0.0));
      let cosAzimuth = select(1.0, dot(dir.xz / max(horizontal, 1e-5), sunHorizontal), horizontal > 1e-5);
      let azimuth = acos(clamp(cosAzimuth, -1.0, 1.0)) / PI;
      let elevation = asin(clamp(dir.y, -1.0, 1.0));
      return vec2f(azimuth, sqrt(clamp((elevation - SKY_LOW) / (PI * 0.5 - SKY_LOW), 0.0, 1.0)));
    }
    
    /** Signed circle of confusion (px, full resolution) at a distance: negative in front of the focus. */
    fn circleOfConfusion(dist: f32) -> f32 {
      return scene.lens.y * (1.0 - scene.lens.x / max(dist, 0.05));
    }
    
    /** One blown particle: pos.w age (s), vel.w life (s); info: seed, settling speed (m/s), size, and 0 at rest or 1 + height above the sand (m) in flight. */
    struct Grain { pos: vec4f, vel: vec4f, info: vec4f };
  • lookups.wgslmodule The precomputed tables every lit shader samples: atmosphere transmittance, the sky seen from the dunes, the sky's irradiance on a surface, and the height above which the low sun still reaches (two cascades). 53 lignes
    Fichier
    dune/prototype-rendu/shaders/lookups.wgsl
    Points d’entrée
    aucun : module assemblé avec d’autres shaders
    Ressources
    6 liaisons
    // PROTOTYPE jetable — the precomputed tables every lit shader samples: atmosphere
    // transmittance, the sky seen from the dunes, the sky's irradiance on a surface, and the
    // height above which the low sun still reaches (two cascades).
    
    @group(0) @binding(1) var transmittanceLut: texture_2d<f32>;
    @group(0) @binding(2) var skyViewLut: texture_2d<f32>;
    @group(0) @binding(3) var irradianceLut: texture_2d<f32>;
    @group(0) @binding(4) var shadowNear: texture_2d<f32>;
    @group(0) @binding(5) var shadowFar: texture_2d<f32>;
    @group(0) @binding(6) var linearClamp: sampler;
    
    /** Share of sunlight left after crossing the atmosphere from `altitude` toward the sun. */
    fn transmittanceTo(altitude: f32, mu: f32) -> vec3f {
      return textureSampleLevel(transmittanceLut, linearClamp, transmittanceUv(altitude, mu), 0.0).rgb;
    }
    
    /** Sun irradiance reaching world height y (m). */
    fn sunlightAt(y: f32) -> vec3f {
      return scene.sunLight.x * transmittanceTo(altitudeOf(y), scene.sun.y);
    }
    
    /** Sky radiance along dir, without the sun's disk. */
    fn skyRadiance(dir: vec3f) -> vec3f {
      return textureSampleLevel(skyViewLut, linearClamp, skyViewUv(dir), 0.0).rgb;
    }
    
    /** Irradiance from the whole sky (and the lit sand below the horizon) on a surface facing n. */
    fn skyIrradiance(n: vec3f) -> vec3f {
      let horizontal = length(n.xz);
      let sunHorizontal = normalize(scene.sun.xz + vec2f(1e-5, 0.0));
      let cosAzimuth = select(1.0, dot(n.xz / max(horizontal, 1e-5), sunHorizontal), horizontal > 1e-5);
      let uv = vec2f(acos(clamp(cosAzimuth, -1.0, 1.0)) / PI, asin(clamp(n.y, -1.0, 1.0)) / PI + 0.5);
      return textureSampleLevel(irradianceLut, linearClamp, uv, 0.0).rgb;
    }
    
    /**
     * How much of the sun's disk a point at height y sees past the dunes upwind of it: each
     * cascade stores the height the sun's rays graze over (x) and how far away that crest is (y).
     */
    fn sunVisibility(p: vec3f) -> f32 {
      var grazing = vec2f(-1e9, 0.0);
      let nearUv = (p.xz - scene.shadowNear.xy) / scene.shadowNear.z;
      let farUv = (p.xz - scene.shadowFar.xy) / scene.shadowFar.z;
      let inNear = all(nearUv > vec2f(0.01)) && all(nearUv < vec2f(0.99));
      let inFar = all(farUv > vec2f(0.005)) && all(farUv < vec2f(0.995));
      let near = textureSampleLevel(shadowNear, linearClamp, nearUv, 0.0).xy;
      let far = textureSampleLevel(shadowFar, linearClamp, farUv, 0.0).xy;
      if (inNear) { grazing = near; } else if (inFar) { grazing = far; }
      // Penumbra: the sun's disk (0.53°) seen from the point, widened by the texel size.
      let texel = select(scene.shadowFar.z / scene.shadowFar.w, scene.shadowNear.z / scene.shadowNear.w, inNear);
      let soft = grazing.y * 0.0047 + texel * 0.35 + 0.05;
      return smoothstep(-soft, soft, p.y - grazing.x);
    }
  • sky-luts.wgslcompute ×3 The atmosphere's lookup tables, recomputed when the sun or the dust change: transmittance (Bruneton), the sky seen from the dunes and its irradiance on a surface (after Hillaire, "A Scalable and Production Ready Sky and Atmosphere", 2020). Single scattering by air, aerosols and a low layer of desert dust, with a rough isotropic term standing in for multiple scattering. 120 lignes
    Fichier
    dune/prototype-rendu/shaders/sky-luts.wgsl
    Points d’entrée
    transmittance compute @workgroup_size(8,8), skyView compute @workgroup_size(8,8), irradiance compute @workgroup_size(8,4)
    Ressources
    3 liaisons
    // PROTOTYPE jetable — the atmosphere's lookup tables, recomputed when the sun or the dust
    // change: transmittance (Bruneton), the sky seen from the dunes and its irradiance on a
    // surface (after Hillaire, "A Scalable and Production Ready Sky and Atmosphere", 2020).
    // Single scattering by air, aerosols and a low layer of desert dust, with a rough isotropic
    // term standing in for multiple scattering.
    
    @group(0) @binding(8) var transmittanceOut: texture_storage_2d<rgba16float, write>;
    @group(0) @binding(9) var skyViewOut: texture_storage_2d<rgba16float, write>;
    @group(0) @binding(10) var irradianceOut: texture_storage_2d<rgba16float, write>;
    
    const GROUND_ALBEDO = vec3f(0.42, 0.3, 0.2);
    
    @compute @workgroup_size(8, 8)
    fn transmittance(@builtin(global_invocation_id) id: vec3u) {
      let size = textureDimensions(transmittanceOut);
      if (id.x >= size.x || id.y >= size.y) { return; }
      let uv = (vec2f(id.xy) + 0.5) / vec2f(size);
      let mu = uv.x * uv.x * 1.25 - 0.25;
      let altitude = uv.y * uv.y * ATMOSPHERE_TOP;
      let r = EARTH_RADIUS + altitude;
      let b = r * mu;
      let ground = b * b - r * r + EARTH_RADIUS * EARTH_RADIUS;
      if (mu < 0.0 && ground > 0.0) {
        textureStore(transmittanceOut, id.xy, vec4f(0.0, 0.0, 0.0, 1.0));
        return;
      }
      let top = EARTH_RADIUS + ATMOSPHERE_TOP;
      let tTop = -b + sqrt(max(b * b - r * r + top * top, 0.0));
      let dir = vec2f(sqrt(max(1.0 - mu * mu, 0.0)), mu);
      var depth = vec3f(0.0);
      let steps = 64;
      for (var i = 0; i < steps; i++) {
        let t0 = tTop * pow(f32(i) / f32(steps), 2.0);
        let t1 = tTop * pow(f32(i + 1) / f32(steps), 2.0);
        let p = vec2f(0.0, r) + dir * (0.5 * (t0 + t1));
        depth += media(length(p) - EARTH_RADIUS).extinction * (t1 - t0);
      }
      textureStore(transmittanceOut, id.xy, vec4f(exp(-depth), 1.0));
    }
    
    /** Light scattered toward the observer along dir, from the observer's radius r0. */
    fn scatterAlong(r0: f32, dir: vec3f, sunDir: vec3f) -> vec3f {
      let b = r0 * dir.y;
      let top = EARTH_RADIUS + ATMOSPHERE_TOP;
      let tTop = -b + sqrt(max(b * b - r0 * r0 + top * top, 0.0));
      let ground = b * b - r0 * r0 + EARTH_RADIUS * EARTH_RADIUS;
      let hitsGround = ground > 0.0 && b < 0.0;
      let tMax = select(tTop, -b - sqrt(max(ground, 0.0)), hitsGround);
      let nu = dot(dir, sunDir);
      let pR = phaseRayleigh(nu);
      let pM = phaseHG(nu, 0.8);
      let pD = phaseDust(nu);
      var throughput = vec3f(1.0);
      var light = vec3f(0.0);
      let steps = 40;
      for (var i = 0; i < steps; i++) {
        let t0 = tMax * pow(f32(i) / f32(steps), 2.0);
        let t1 = tMax * pow(f32(i + 1) / f32(steps), 2.0);
        let p = vec3f(0.0, r0, 0.0) + dir * (0.5 * (t0 + t1));
        let r = length(p);
        let altitude = r - EARTH_RADIUS;
        let m = media(altitude);
        let sun = transmittanceTo(altitude, dot(p / r, sunDir));
        let scattering = m.rayleigh + vec3f(m.mie) + m.dust;
        let high = transmittanceTo(max(altitude, MULTIPLE_SCATTERING_ALTITUDE), dot(p / r, sunDir));
        let source = (m.rayleigh * pR + vec3f(m.mie * pM) + m.dust * pD) * sun + scattering * MULTIPLE_SCATTERING * high;
        let segment = exp(-m.extinction * (t1 - t0));
        light += throughput * source * (1.0 - segment) / max(m.extinction, vec3f(1e-12));
        throughput *= segment;
      }
      if (hitsGround) {
        let sun = transmittanceTo(0.0, max(sunDir.y, 0.0));
        light += throughput * GROUND_ALBEDO / PI * sun * max(sunDir.y, 0.0);
      }
      return light * scene.sunLight.x;
    }
    
    @compute @workgroup_size(8, 8)
    fn skyView(@builtin(global_invocation_id) id: vec3u) {
      let size = textureDimensions(skyViewOut);
      if (id.x >= size.x || id.y >= size.y) { return; }
      let uv = (vec2f(id.xy) + 0.5) / vec2f(size);
      let azimuth = uv.x * PI;
      let elevation = SKY_LOW + (PI * 0.5 - SKY_LOW) * uv.y * uv.y;
      // In this table's frame the sun lies along +x.
      let dir = vec3f(cos(elevation) * cos(azimuth), sin(elevation), cos(elevation) * sin(azimuth));
      let sunDir = vec3f(cos(skySunElevation()), sin(skySunElevation()), 0.0);
      let light = scatterAlong(EARTH_RADIUS + altitudeOf(scene.eye.y), dir, sunDir);
      textureStore(skyViewOut, id.xy, vec4f(light, 1.0));
    }
    
    @compute @workgroup_size(8, 4)
    fn irradiance(@builtin(global_invocation_id) id: vec3u) {
      let size = textureDimensions(irradianceOut);
      if (id.x >= size.x || id.y >= size.y) { return; }
      let uv = (vec2f(id.xy) + 0.5) / vec2f(size);
      let azimuth = uv.x * PI;
      let elevation = (uv.y - 0.5) * PI;
      let sunH = normalize(scene.sun.xz + vec2f(1e-5, 0.0));
      let toward = vec3f(sunH.x, 0.0, sunH.y);
      let side = vec3f(-sunH.y, 0.0, sunH.x);
      let n = normalize(toward * cos(elevation) * cos(azimuth) + vec3f(0.0, sin(elevation), 0.0) + side * cos(elevation) * sin(azimuth));
      let t1 = normalize(select(cross(n, vec3f(0.0, 1.0, 0.0)), vec3f(1.0, 0.0, 0.0), abs(n.y) > 0.999));
      let t2 = cross(n, t1);
      // The sand below the horizon, lit by the low sun and the sky.
      let groundSun = scene.sunLight.x * transmittanceTo(altitudeOf(0.0), scene.sun.y) * max(scene.sun.y, 0.0);
      let ground = GROUND_ALBEDO / PI * (groundSun + PI * skyRadiance(vec3f(0.0, 1.0, 0.0)) * 0.7);
      var sum = vec3f(0.0);
      let count = 160;
      for (var k = 0; k < count; k++) {
        // Cosine-weighted Fibonacci points over the hemisphere around n.
        let fk = (f32(k) + 0.5) / f32(count);
        let radius = sqrt(fk);
        let angle = f32(k) * 2.39996323;
        let local = vec3f(radius * cos(angle), radius * sin(angle), sqrt(max(1.0 - fk, 0.0)));
        let dir = t1 * local.x + t2 * local.y + n * local.z;
        sum += select(ground, skyGrade(dir, skyRadiance(dir)), dir.y > 0.0);
      }
      textureStore(irradianceOut, id.xy, vec4f(sum / f32(count) * PI, 1.0));
    }
  • shadow.wgslcompute Sun shadows of the dunes on one another. For every texel, march toward the low sun and keep the highest line its rays graze over: a point is sunlit when it stands above that height, whether it is sand or a grain in the air. The distance to the grazed crest widens the penumbra. 28 lignes
    Fichier
    dune/prototype-rendu/shaders/shadow.wgsl
    Points d’entrée
    shadowHeights compute @workgroup_size(8,8)
    Ressources
    2 liaisons
    // PROTOTYPE jetable — sun shadows of the dunes on one another. For every texel, march
    // toward the low sun and keep the highest line its rays graze over: a point is sunlit when
    // it stands above that height, whether it is sand or a grain in the air. The distance to the
    // grazed crest widens the penumbra.
    
    @group(0) @binding(8) var<uniform> cascade: vec4f;   // x0, z0 (m), size (m), texels
    @group(0) @binding(9) var shadowOut: texture_storage_2d<rgba16float, write>;
    
    @compute @workgroup_size(8, 8)
    fn shadowHeights(@builtin(global_invocation_id) id: vec3u) {
      let texels = u32(cascade.w);
      if (id.x >= texels || id.y >= texels) { return; }
      let texel = cascade.z / cascade.w;
      let p = cascade.xy + (vec2f(id.xy) + 0.5) * texel;
      let toward = normalize(scene.sun.xz + vec2f(1e-5, 0.0));
      let rise = scene.sun.y / max(length(scene.sun.xz), 1e-4);
      var grazing = -1e9;
      var distance = 0.0;
      var t = texel * 1.5;
      for (var i = 0; i < 520; i++) {
        let h = terrainHeight(p + toward * t) - t * rise;
        if (h > grazing) { grazing = h; distance = t; }
        // Nothing on the dune field stands 70 m above its floor.
        if (t > 4000.0 || (t * rise > 90.0 && grazing > FLOOR + 70.0 - t * rise)) { break; }
        t += max(texel * 0.7, t * 0.012);
      }
      textureStore(shadowOut, id.xy, vec4f(grazing, distance, 0.0, 1.0));
    }
  • terrain.wgslvertexfragment The sand. A polar grid centred on the observer (dense at their feet, 40 km out at the horizon) carries the dunes' shape; every pixel then lights its own ripples. Wind ripples are a phase field (sinuous, forking where the noise bends them), asymmetric like real ones (gentle stoss, steeper lee), and they shadow each other under the grazing sun. When a pixel covers several ripples, it averages their light over a period instead of aliasing. Grains glitter where one facet happens to mirror the sun. 442 lignes
    Fichier
    dune/prototype-rendu/shaders/terrain.wgsl
    Points d’entrée
    vs_terrain vertex, fs_terrain fragment
    Ressources
    1 liaison
    // PROTOTYPE jetable — the sand. A polar grid centred on the observer (dense at their feet,
    // 40 km out at the horizon) carries the dunes' shape; every pixel then lights its own
    // ripples. Wind ripples are a phase field (sinuous, forking where the noise bends them),
    // asymmetric like real ones (gentle stoss, steeper lee), and they shadow each other under the
    // grazing sun. When a pixel covers several ripples, it averages their light over a period
    // instead of aliasing. Grains glitter where one facet happens to mirror the sun.
    
    @group(0) @binding(8) var trailTex: texture_2d<f32>;
    
    struct TerrainOut {
      @builtin(position) clip: vec4f,
      @location(0) world: vec3f,
    };
    
    @vertex fn vs_terrain(@builtin(vertex_index) index: u32) -> TerrainOut {
      let columns = GRID_SEGMENTS + 1u;
      let ring = f32(index / columns);
      let segment = f32(index % columns);
      let angle = scene.grid.x + (segment / f32(GRID_SEGMENTS) * 2.0 - 1.0) * scene.grid.y;
      let radius = scene.grid.z * pow(scene.grid.w / scene.grid.z, ring / f32(GRID_RINGS));
      let xz = scene.eye.xz + radius * vec2f(sin(angle), cos(angle));
      // The Earth's curvature lowers the far ridges a little.
      let y = terrainHeight(xz) - radius * radius / (2.0 * EARTH_RADIUS);
      var out: TerrainOut;
      out.world = vec3f(xz.x, y, xz.y);
      out.clip = scene.viewProj * vec4f(out.world, 1.0);
      return out;
    }
    
    // ---------------------------------------------------------------- ripples
    
    struct Ripples { phase: f32, grad: vec2f, height: f32 };
    
    /** A family of ripples moving along dir: phase (cycles), its gradient, crest-to-trough height (m). */
    fn rippleSet(p: vec2f, dir: vec2f, wavelength: f32, bend: f32, relief: f32, seed: f32) -> Ripples {
      let s1 = wavelength * 13.0;
      let s2 = wavelength * 3.7;
      let n1 = noised(p / s1 + seed);
      let n2 = noised(p / s2 + seed * 1.7 + 11.0);
      let phase = dot(p, dir) / wavelength + (n1.x - 0.5) * bend * 2.4 + (n2.x - 0.5) * bend * 0.55;
      let grad = dir / wavelength + n1.yz / s1 * bend * 2.4 + n2.yz / s2 * bend * 0.55;
      // Crests are not all equal: some fade out, some stand taller.
      let vary = noise(p / (wavelength * 6.0) + seed * 3.1);
      return Ripples(phase, grad, wavelength * relief * (0.4 + 1.2 * vary));
    }
    
    /** Where the crest sits in a ripple's period: a long gentle stoss, then a steeper lee. */
    const RIPPLE_CREST: f32 = 0.7;
    
    /** Ripple profile at a phase: height in 0..1 (x) and its slope per cycle (y). */
    fn rippleShape(phase: f32) -> vec2f {
      let x = fract(phase);
      if (x < RIPPLE_CREST) {
        let a = x / RIPPLE_CREST;
        return vec2f(0.5 - 0.5 * cos(PI * a), 0.5 * PI * sin(PI * a) / RIPPLE_CREST);
      }
      let b = (x - RIPPLE_CREST) / (1.0 - RIPPLE_CREST);
      return vec2f(0.5 + 0.5 * cos(PI * b), -0.5 * PI * sin(PI * b) / (1.0 - RIPPLE_CREST));
    }
    
    /** Rough, dusty sand: Oren–Nayar (σ ≈ 0.5), qualitative form. Includes the cosine. */
    fn orenNayar(n: vec3f, l: vec3f, v: vec3f) -> f32 {
      let nl = dot(n, l);
      if (nl <= 0.0) { return 0.0; }
      let nv = max(dot(n, v), 1e-3);
      let sigma2 = 0.25;
      let a = 1.0 - 0.5 * sigma2 / (sigma2 + 0.33);
      let b = 0.45 * sigma2 / (sigma2 + 0.09);
      let s = dot(l, v) - nl * nv;
      let t = select(1.0, max(nl, nv), s > 0.0);
      return nl * (a + b * s / t);
    }
    
    struct RippleLight { direct: f32, lit: f32, slope: vec2f, height: f32 };
    
    /**
     * Sunlight on one point of two ripple families (A fine, B coarse): the diffuse term, and the
     * share of the sun not hidden by the ripples upwind, marched toward the sun over one period.
     * `rise` is the sun's elevation (tangent) over the macro surface, along `toward`.
     */
    fn rippleSun(phaseA: f32, gradA: vec2f, heightA: f32, phaseB: f32, gradB: vec2f, heightB: f32,
                 macroSlope: vec2f, l: vec3f, v: vec3f, toward: vec2f, rise: f32) -> RippleLight {
      let a = rippleShape(phaseA);
      let b = rippleShape(phaseB);
      let slope = macroSlope + heightA * a.y * gradA + heightB * b.y * gradB;
      let n = normalize(vec3f(-slope.x, 1.0, -slope.y));
      var lit = 0.0;
      var direct = 0.0;
      if (dot(n, l) > 0.0 && rise > 0.0) {
        lit = 1.0;
        let kA = dot(gradA, toward);
        let kB = dot(gradB, toward);
        let spanA = min(1.0 / max(abs(kA), 1e-3), 0.4);
        let slopeB = heightB * b.y * kB;
        for (var j = 1; j <= 5; j++) {
          let t = spanA * f32(j) * 0.2;
          let up = heightA * (rippleShape(phaseA + kA * t).x - a.x) + slopeB * t;
          let soft = 0.012 * t + 2e-4;
          lit = min(lit, smoothstep(-soft, soft, t * rise - up));
        }
        let spanB = min(1.0 / max(abs(kB), 1e-3), 2.0);
        for (var j = 1; j <= 5; j++) {
          let t = spanB * f32(j) * 0.2;
          let up = heightB * (rippleShape(phaseB + kB * t).x - b.x);
          let soft = 0.012 * t + 2e-4;
          lit = min(lit, smoothstep(-soft, soft, t * rise - up));
        }
        direct = orenNayar(n, l, v) * lit;
      }
      return RippleLight(direct, lit, slope, a.x);
    }
    
    // ---------------------------------------------------------------- grains
    
    fn sandAlbedo(p: vec2f, pixel: f32, crest: f32, slip: f32, fall: vec2f) -> vec3f {
      var albedo = vec3f(0.6, 0.45, 0.28);
      // Slip faces are combed by grainflows: tongues of sand running down the fall line.
      let across = vec2f(-fall.y, fall.x);
      let flows = noise(vec2f(dot(p, across) * 0.9, dot(p, fall) * 0.12)) * 0.7 + noise(vec2f(dot(p, across) * 3.1, dot(p, fall) * 0.4) + 7.0) * 0.3;
      albedo *= 1.0 + 0.22 * (flows - 0.5) * slip;
      // Coarser, darker and redder grains gather on ripple crests.
      albedo *= mix(vec3f(1.0), vec3f(0.84, 0.8, 0.78), crest);
      // Fresh avalanche sand on the slip faces is a little paler.
      albedo *= mix(vec3f(1.0), vec3f(1.05, 1.06, 1.07), slip);
      let fine = (noise(p / 0.006) - 0.5) * (1.0 - smoothstep(0.002, 0.008, pixel));
      let mid = (noise(p / 0.035 + 7.0) - 0.5) * (1.0 - smoothstep(0.012, 0.05, pixel));
      let broad = fbm(p / 3.0, 2) - 0.5;
      return albedo * (1.0 + 0.35 * fine + 0.18 * mid + 0.12 * broad);
    }
    
    /**
     * Sand at the scale of its grains: the nearest of a jittered grid of grains (Worley), each
     * with its own tone (pale quartz, a few dark heavy minerals) and a tiny dome that catches
     * the grazing sun on one side; lumps a few centimetres across ride on top. Each scale fades
     * out once the pixel no longer resolves it, so nothing shimmers.
     */
    struct SandGrains { tone: f32, slope: vec2f };
    
    fn nearestGrain(p: vec2f, size: f32) -> vec4f {
      let q = p / size;
      let g = floor(q);
      let f = q - g;
      var best = 9.0;
      var toCenter = vec2f(0.0);
      var id = 0.0;
      for (var j = -1; j <= 1; j++) {
        for (var i = -1; i <= 1; i++) {
          let c = g + vec2f(f32(i), f32(j));
          let d = vec2f(f32(i), f32(j)) + hash22(c + 0.71) - f;
          let dd = dot(d, d);
          if (dd < best) { best = dd; toCenter = d; id = hash2(c + 5.7); }
        }
      }
      return vec4f(sqrt(best), toCenter, id);
    }
    
    fn sandGrains(p: vec2f, pixel: f32) -> SandGrains {
      var out = SandGrains(1.0, vec2f(0.0));
      let size = 0.003;
      let fine = 1.0 - smoothstep(0.0015, 0.0045, pixel);
      if (fine > 0.0) {
        let grain = nearestGrain(p, size);
        let radius = 0.62;
        let inside = max(1.0 - dot(grain.yz, grain.yz) / (radius * radius), 0.0);
        // A grain ~0.8 mm proud of its neighbours: the sand rises toward its centre.
        let height = 0.0008;
        out.slope = 2.0 * height * grain.yz / (radius * radius) / size * step(1e-4, inside) * fine;
        var tone = mix(0.78, 1.22, grain.w);
        tone = select(tone, 0.5, grain.w < 0.04);
        tone = select(tone, 1.45, grain.w > 0.96);
        // The gaps between grains sit in their shade.
        out.tone = mix(1.0, tone * mix(0.72, 1.0, smoothstep(0.0, 0.35, inside)), fine);
      }
      let lumps = 1.0 - smoothstep(0.006, 0.03, pixel);
      if (lumps > 0.0) {
        let a = noised(p / 0.016 + 3.3);
        let b = noised(p / 0.041 + 9.1);
        out.slope += (a.yz / 0.016 * 0.0045 + b.yz / 0.041 * 0.008) * lumps;
        out.tone *= mix(1.0, 0.82 + 0.2 * a.x + 0.16 * b.x, lumps);
      }
      return out;
    }
    
    /**
     * Clods and small pits scattered in the sand, as in the concept's foreground: pits are soft
     * bowls with a low rim (old hollows the wind has already worn), clods are lumps of crusted
     * sand with ragged outlines. They come in patches, one at most per cell of a jittered grid;
     * the two nearest are kept, so their height can be sampled cheaply again for their shadows.
     */
    const FEATURE_CELL: f32 = 0.24;
    
    /** A pit or a clod: centre, radius, depth or height, kind (0 pit, 1 clod), long axis, stretch. */
    struct Feature { center: vec2f, radius: f32, amount: f32, kind: f32, axis: vec2f, stretch: f32 };
    
    fn featureProfile(f: Feature, q: vec2f) -> f32 {
      let d = q - f.center;
      // Oval, turned any way, with an outline that wavers.
      let local = vec2f(dot(d, f.axis), dot(d, vec2f(-f.axis.y, f.axis.x)) * f.stretch);
      let r = length(local);
      let wobble = 1.0 + 0.5 * (noise(local / max(r, 1e-5) * 1.4 + f.amount * 700.0) - 0.5) + 0.25 * (noise(q * 90.0) - 0.5);
      let x = r / (f.radius * wobble);
      if (f.kind < 0.5) {
        // Pit: a worn bowl, and a rim only where the sand heaped up.
        let bowl = max(1.0 - x * x, 0.0);
        let rim = 0.25 * f.amount * exp(-pow((x - 1.1) / 0.35, 2.0)) * noise(local * 60.0 + 3.0);
        return -f.amount * pow(bowl, 1.4) + rim;
      }
      // Clod: a lump of crusted sand.
      return f.amount * pow(max(1.0 - x * x, 0.0), 0.6);
    }
    
    struct Features { a: Feature, b: Feature };
    
    fn nearestFeatures(p: vec2f) -> Features {
      let none = Feature(vec2f(1e6), 1.0, 0.0, 0.0, vec2f(1.0, 0.0), 1.0);
      var out = Features(none, none);
      var bestA = 1e9;
      var bestB = 1e9;
      let g = floor(p / FEATURE_CELL);
      // Patches: trampled-looking stretches next to smooth ones.
      let density = smoothstep(0.3, 0.75, noise(p / 1.7 + 17.0));
      for (var j = -1; j <= 1; j++) {
        for (var i = -1; i <= 1; i++) {
          let c = g + vec2f(f32(i), f32(j));
          let h = hash22(c + 41.3);
          let pick = hash2(c + 12.9);
          if (pick > 0.5 * density) { continue; }
          let center = (c + 0.25 + 0.5 * h) * FEATURE_CELL;
          let size = hash2(c + 77.1);
          let turn = hash2(c + 5.3) * PI;
          let axis = vec2f(cos(turn), sin(turn));
          var feature = Feature(center, mix(0.04, 0.1, size), mix(0.007, 0.02, size), 0.0, axis, mix(1.0, 1.9, hash2(c + 8.8)));
          if (pick < 0.2 * density) {
            feature = Feature(center, mix(0.02, 0.05, size), mix(0.008, 0.022, size), 1.0, axis, mix(1.0, 1.6, hash2(c + 8.8)));
          }
          let dist = length(p - center) - feature.radius;
          if (dist < bestA) {
            bestB = bestA;
            out.b = out.a;
            bestA = dist;
            out.a = feature;
          } else if (dist < bestB) {
            bestB = dist;
            out.b = feature;
          }
        }
      }
      return out;
    }
    
    fn featuresHeight(f: Features, q: vec2f) -> f32 {
      return featureProfile(f.a, q) + featureProfile(f.b, q);
    }
    
    /** x: height offset, yz: slope, w: share of the sun they let through. */
    fn sandFeatures(p: vec2f, pixel: f32, toward: vec2f, rise: f32) -> vec4f {
      let visible = 1.0 - smoothstep(0.008, 0.03, pixel);
      if (visible <= 0.0) { return vec4f(0.0, 0.0, 0.0, 1.0); }
      let f = nearestFeatures(p);
      let here = featuresHeight(f, p);
      let e = 0.0015;
      let slope = vec2f(
        featuresHeight(f, p + vec2f(e, 0.0)) - featuresHeight(f, p - vec2f(e, 0.0)),
        featuresHeight(f, p + vec2f(0.0, e)) - featuresHeight(f, p - vec2f(0.0, e))) / (2.0 * e);
      var lit = 1.0;
      if (abs(here) > 1e-4 || dot(slope, slope) > 1e-4) {
        for (var j = 1; j <= 6; j++) {
          let t = 0.03 * f32(j);
          let up = featuresHeight(f, p + toward * t) - here;
          let soft = 0.04 * t + 3e-4;
          lit = min(lit, smoothstep(-soft, soft, t * rise - up));
        }
      }
      return vec4f(here, slope * visible, mix(1.0, lit, visible));
    }
    
    /**
     * One sparkle per grain-sized cell (a power of two above the pixel, so it stays put), at a
     * random point in the cell and about a pixel wide, so sparkles read as points, not squares.
     */
    fn glitter(p: vec2f, n: vec3f, l: vec3f, v: vec3f, pixel: f32) -> f32 {
      let h = normalize(l + v);
      let size = exp2(ceil(log2(max(pixel * 1.5, 0.0012))));
      let cell = floor(p / size);
      let r = hash22(cell + 0.37);
      let r2 = hash22(cell * 1.31 + 17.7);
      let t1 = normalize(cross(n, vec3f(0.0, 0.0, 1.0)));
      let t2 = cross(n, t1);
      // Grain facets point almost anywhere: a wide Gaussian around the surface normal.
      let g = sqrt(-2.0 * log(max(r.x, 1e-5))) * vec2f(cos(TAU * r.y), sin(TAU * r.y)) * 0.8;
      let facet = normalize(n + t1 * g.x + t2 * g.y);
      let lobe = pow(max(dot(facet, h), 0.0), 500.0);
      let delta = (fract(p / size) - (0.2 + 0.6 * r2.yx)) * size / max(pixel, 1e-4);
      let spot = exp(-dot(delta, delta) * 1.6);
      // Only some grains are clear quartz.
      return lobe * spot * select(0.0, 1.0, r2.x < 0.3) * 2.0;
    }
    
    // ---------------------------------------------------------------- the trace
    
    /** The drawn trace at p: height offset (m) and how much of the ripples it wiped out. */
    fn trailAt(p: vec2f) -> vec2f {
      let uv = (p - scene.trail.xy) / scene.trail.z;
      let inside = smoothstep(0.0, 0.01, min(uv.x, uv.y)) * smoothstep(0.0, 0.01, 1.0 - max(uv.x, uv.y));
      return textureSampleLevel(trailTex, linearClamp, uv, 0.0).xy * inside;
    }
    
    // ---------------------------------------------------------------- shading
    
    @fragment fn fs_terrain(in: TerrainOut) -> @location(0) vec4f {
      let p = in.world;
      let toEye = scene.eye.xyz - p;
      let dist = length(toEye);
      let v = toEye / dist;
      // Pixel footprint on the ground (derivatives in uniform control flow).
      let dPx = dpdx(p.xz);
      let dPy = dpdy(p.xz);
      let pixel = dist * 2.0 * scene.lens.z * scene.viewport.w;
      let mode = viewMode();
      let l = scene.sun.xyz;
    
      let n = terrainNormal(p.xz, max(pixel * 0.75, 0.01));
      let macroSlope = vec2f(-n.x, -n.z) / n.y;
      let toward = normalize(l.xz + vec2f(1e-5, 0.0));
      let rise = l.y / max(length(l.xz), 1e-4) - dot(macroSlope, toward);
    
      // The trace: its slope tilts the surface, it wipes out ripples, and its walls shade it.
      var trailSlope = vec2f(0.0);
      var erased = 0.0;
      var trailLit = 1.0;
      let trailUv = (p.xz - scene.trail.xy) / scene.trail.z;
      if (scene.sim.w > 0.5 && all(trailUv > vec2f(0.0)) && all(trailUv < vec2f(1.0))) {
        let e = scene.trail.z / scene.trail.w;
        let here = trailAt(p.xz);
        let hx = trailAt(p.xz + vec2f(e, 0.0)).x - trailAt(p.xz - vec2f(e, 0.0)).x;
        let hz = trailAt(p.xz + vec2f(0.0, e)).x - trailAt(p.xz - vec2f(0.0, e)).x;
        trailSlope = vec2f(hx, hz) / (2.0 * e);
        erased = here.y;
        if (abs(here.x) > 1e-4 || length(trailSlope) > 1e-3) {
          for (var j = 1; j <= 6; j++) {
            let t = e * 2.5 * f32(j);
            let up = trailAt(p.xz + toward * t).x - here.x;
            let soft = 0.015 * t + 2e-4;
            trailLit = min(trailLit, smoothstep(-soft, soft, t * rise - up));
          }
        }
      }
      // Clods and pits near the observer: their slope tilts the surface too, and they shade.
      var features = vec4f(0.0, 0.0, 0.0, 1.0);
      if (mode != VIEW_SHAPE) { features = sandFeatures(p.xz, pixel, toward, rise - dot(trailSlope, toward)); }
      let featureMask = clamp(length(features.yz) * 3.0, 0.0, 1.0);
      let surfaceSlope = macroSlope + trailSlope + features.yz;
      let surfaceRise = rise - dot(trailSlope + features.yz, toward);
    
      // Ripples fade out on slopes too steep to keep them (the slip faces avalanche instead).
      let steep = 1.0 - n.y;
      let keep = (1.0 - smoothstep(0.07, 0.12, steep)) * scene.look.z;
      let windAngle = scene.wind.w;
      let farDir = vec2f(cos(windAngle), sin(windAngle));
      let fine = rippleSet(p.xz, farDir, 0.115, 1.25, 0.058, 3.0);
      let coarse = rippleSet(p.xz, normalize(farDir + vec2f(0.15, 0.1)), 0.72, 0.9, 0.009, 21.0);
      // Ripples do not run through a pit or over a clod.
      let heightA = fine.height * keep * (1.0 - erased) * (1.0 - featureMask);
      let heightB = coarse.height * keep * (1.0 - erased) * (1.0 - 0.6 * featureMask);
    
      var direct = 0.0;
      var lit = 0.0;
      var microSlope = surfaceSlope;
      var crestiness = 0.5;
      if (mode == VIEW_SHAPE) {
        direct = orenNayar(n, l, v);
        lit = 1.0;
      } else {
        // How many ripples the pixel spans: supersample the footprint, or average over a period.
        let wA = abs(dot(fine.grad, dPx)) + abs(dot(fine.grad, dPy));
        let wB = abs(dot(coarse.grad, dPx)) + abs(dot(coarse.grad, dPy));
        let averageA = wA > 1.2;
        let averageB = wB > 1.2;
        let count = select(clamp(i32(ceil(max(wA, wB) * 3.0)), 1, 8), 8, averageA || averageB);
        var offsets = array<vec2f, 8>(
          vec2f(0.0625, -0.1875), vec2f(-0.0625, 0.1875), vec2f(0.3125, 0.0625), vec2f(-0.1875, -0.3125),
          vec2f(-0.3125, 0.3125), vec2f(-0.4375, -0.0625), vec2f(0.1875, 0.4375), vec2f(0.4375, -0.4375));
        var sum = RippleLight(0.0, 0.0, vec2f(0.0), 0.0);
        for (var k = 0; k < count; k++) {
          let o = select(offsets[k], vec2f(0.0), count == 1);
          let d = dPx * o.x + dPy * o.y;
          let phaseA = select(fine.phase + dot(fine.grad, d), (f32(k) + 0.5) * 0.125, averageA);
          let phaseB = select(coarse.phase + dot(coarse.grad, d), fract(f32(k) * 0.618 + 0.31), averageB);
          let one = rippleSun(phaseA, fine.grad, heightA, phaseB, coarse.grad, heightB, surfaceSlope, l, v, toward, surfaceRise);
          sum.direct += one.direct;
          sum.lit += one.lit;
          sum.slope += one.slope;
          sum.height += one.height;
        }
        let inv = 1.0 / f32(count);
        direct = sum.direct * inv * trailLit * features.w;
        lit = sum.lit * inv * trailLit * features.w;
        microSlope = sum.slope * inv;
        crestiness = sum.height * inv;
      }
      let micro = normalize(vec3f(-microSlope.x, 1.0, -microSlope.y));
    
      // Grains and lumps: their tone, and their sun-facing sides lit brighter under the grazing sun.
      let grains = sandGrains(p.xz, pixel);
      // A facet that climbs toward the sun sees it lower: darker; one that falls toward it, brighter.
      let grainShade = clamp(1.0 - dot(grains.slope, toward) / max(surfaceRise, 0.06), 0.15, 2.4);
      if (mode != VIEW_SHAPE) { direct *= grainShade; }
      let sun = sunlightAt(p.y) * sunVisibility(p) * SUN_ON_SAND;
      let ripples = keep * clamp(heightA / 0.008, 0.0, 1.0);
      let cavity = 1.0 - 0.28 * (1.0 - crestiness) * ripples;
      let ambient = skyIrradiance(micro) * cavity * SKY_ON_SAND;
      let slip = smoothstep(0.1, 0.16, steep);
      let fall = normalize(macroSlope + vec2f(1e-5, 0.0));
      // Freshly stirred sand is a touch darker, and shows fresh quartz facets.
      var albedo = sandAlbedo(p.xz, pixel, smoothstep(0.6, 0.95, crestiness) * ripples, slip, fall) * (1.0 - 0.06 * erased) * grains.tone;
      if (mode == VIEW_SHAPE || mode == VIEW_RIPPLES) { albedo = vec3f(0.5); }
    
      // Glitter near the observer; far away it melts into a faint sheen.
      let near = exp(-dist / 12.0);
      // A defocused sparkle spreads its light over its circle of confusion.
      let spread = 1.0 + 0.25 * circleOfConfusion(dist) * circleOfConfusion(dist);
      let sparkle = glitter(p.xz, micro, l, v, pixel) * lit * near * scene.look.w * 3.0 * (1.0 + erased) / spread;
      let h = normalize(l + v);
      let sheen = pow(max(dot(micro, h), 0.0), 24.0) * 0.035 * lit;
    
      // Ripple flanks in shadow face lit ones: the sunlit stoss next door (not this pixel, which
      // is dark) bounces warm light into the troughs.
      let litFlank = clamp(0.15 + surfaceRise * 2.5, 0.0, 0.6);
      let hollows = max(ripples, 0.5 * max(erased, featureMask));
      let bounce = albedo * sun * litFlank * 0.45 * (1.0 - lit) * hollows;
      var color = albedo / PI * (sun * direct + ambient + bounce) + sun * (sparkle + sheen);
      if (mode == VIEW_SHAPE || mode == VIEW_RIPPLES) {
        color = albedo / PI * (sun * direct + ambient);
      } else if (mode == VIEW_GLITTER) {
        color = sun * sparkle + albedo / PI * ambient * 0.15;
      } else if (mode == VIEW_SHADOWS) {
        color = vec3f(sunVisibility(p) * lit * 0.35 + 0.01);
      } else if (mode == VIEW_SPRAY) {
        color = vec3f(0.0);
      }
      return vec4f(color, dist);
    }
  • atmosphere.wgslfragment The air between the observer and everything: the sky (thin clouds, the sun's disk) where nothing was drawn, and aerial perspective over the sand, integrated along each view ray through the same air, aerosols and dust as the sky tables. 97 lignes
    Fichier
    dune/prototype-rendu/shaders/atmosphere.wgsl
    Points d’entrée
    fs_atmosphere fragment
    Ressources
    1 liaison
    // PROTOTYPE jetable — the air between the observer and everything: the sky (thin clouds, the
    // sun's disk) where nothing was drawn, and aerial perspective over the sand, integrated along
    // each view ray through the same air, aerosols and dust as the sky tables.
    
    @group(0) @binding(8) var sceneColor: texture_2d<f32>;
    
    /** Distance written where no sand was drawn. */
    const SKY_DISTANCE: f32 = 60000.0;
    
    fn viewRay(uv: vec2f) -> vec3f {
      let near = scene.invViewProj * vec4f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 1.0, 1.0);
      return normalize(near.xyz / near.w - scene.eye.xyz);
    }
    
    struct Air { inscatter: vec3f, transmittance: vec3f };
    
    fn aerial(dir: vec3f, dist: f32) -> Air {
      let sky = skySun();
      let nu = dot(dir, sky);
      let pR = phaseRayleigh(nu);
      let pM = phaseHG(nu, 0.8);
      let pD = phaseDust(nu);
      var throughput = vec3f(1.0);
      var light = vec3f(0.0);
      let steps = 14;
      for (var i = 0; i < steps; i++) {
        let t0 = dist * pow(f32(i) / f32(steps), 2.0);
        let t1 = dist * pow(f32(i + 1) / f32(steps), 2.0);
        let t = 0.5 * (t0 + t1);
        // The ground curves away under long rays.
        let altitude = altitudeOf(scene.eye.y + dir.y * t) + t * t / (2.0 * EARTH_RADIUS);
        var m = media(altitude);
        // Denser low dust along the view (art direction, see HAZE_ON_VIEW).
        let extra = m.dust * (HAZE_ON_VIEW - 1.0);
        m.dust += extra;
        m.extinction += extra + DUST_ABSORB / DUST_SCATTER * extra;
        let sun = transmittanceTo(altitude, sky.y);
        let scattering = m.rayleigh + vec3f(m.mie) + m.dust;
        let high = transmittanceTo(max(altitude, MULTIPLE_SCATTERING_ALTITUDE), sky.y);
        let source = (m.rayleigh * pR + vec3f(m.mie * pM) + m.dust * pD) * sun + scattering * MULTIPLE_SCATTERING * high;
        let segment = exp(-m.extinction * (t1 - t0));
        light += throughput * source * (1.0 - segment) / max(m.extinction, vec3f(1e-12));
        throughput *= segment;
      }
      return Air(light * scene.sunLight.x * HAZE_TINT, throughput);
    }
    
    /** Thin streaks of altostratus 6.5 km up, lit from below by the setting sun. */
    fn clouds(dir: vec3f, sky: vec3f) -> vec3f {
      if (dir.y < 0.004 || scene.haze.w <= 0.0) { return sky; }
      let height = 6500.0;
      let t = (height - scene.eye.y) / dir.y;
      let hit = scene.eye.xz + dir.xz * t;
      let q = mat2x2f(0.94, 0.34, -0.34, 0.94) * hit;
      let uv = q / vec2f(9000.0, 2600.0) + vec2f(scene.eye.w * 0.0012, 0.0);
      let warp = vec2f(fbm(uv * 0.8 + 5.0, 3), fbm(uv * 0.8 + 9.0, 3)) - 0.5;
      let n = fbm(uv + warp * 1.6, 5) * (0.55 + 0.9 * fbm(q / 40000.0 + 2.0, 2));
      // A low band of streaks on the sun's side, as in the concept.
      let sunward = max(dot(normalize(dir.xz + vec2f(1e-5, 0.0)), normalize(scene.sun.xz + vec2f(1e-5, 0.0))), 0.0);
      let lowBand = smoothstep(0.03, 0.07, dir.y) * (1.0 - smoothstep(0.1, 0.18, dir.y)) * sunward;
      let cover = clamp(scene.haze.w + 0.45 * lowBand, 0.0, 1.0);
      let density = smoothstep(0.66 - 0.22 * cover, 0.92 - 0.18 * cover, n) * (1.0 - smoothstep(90000.0, 220000.0, t));
      let alpha = (1.0 - exp(-density * 2.2)) * 0.9;
      let altitude = altitudeOf(height);
      let sunAtCloud = scene.sunLight.x * transmittanceTo(altitude, skySun().y);
      let nu = dot(dir, skySun());
      let phase = 0.65 * phaseHG(nu, 0.6) + 0.35 * phaseHG(nu, -0.2);
      let light = sunAtCloud * phase * 1.4 + skyRadiance(vec3f(0.0, 1.0, 0.0)) * 0.7;
      // Far clouds sink into the haze of the horizon.
      let seen = alpha * exp(-t / 190000.0);
      return mix(sky, light, seen);
    }
    
    fn sunDisk(dir: vec3f) -> vec3f {
      let radius = 0.00465;
      let angle = acos(clamp(dot(dir, scene.sun.xyz), -1.0, 1.0));
      if (angle > radius) { return vec3f(0.0); }
      let r = angle / radius;
      let limb = 1.0 - 0.6 * (1.0 - sqrt(max(1.0 - r * r, 0.0)));
      let radiance = scene.sunLight.x / (PI * radius * radius) * transmittanceTo(altitudeOf(scene.eye.y), scene.sun.y) * limb;
      return min(radiance, vec3f(30000.0));
    }
    
    @fragment fn fs_atmosphere(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let texel = textureLoad(sceneColor, vec2i(position.xy), 0);
      let mode = viewMode();
      let dir = viewRay(uv);
      if (mode == VIEW_SPRAY) { return vec4f(0.0, 0.0, 0.0, texel.a); }
      if (texel.a > SKY_DISTANCE * 0.8) {
        if (mode == VIEW_GLITTER || mode == VIEW_SHADOWS) { return vec4f(0.0, 0.0, 0.0, SKY_DISTANCE); }
        let sky = skyGrade(dir, clouds(dir, skyRadiance(dir))) + sunDisk(dir);
        return vec4f(sky, SKY_DISTANCE);
      }
      if (mode == VIEW_GLITTER || mode == VIEW_SHADOWS) { return texel; }
      let air = aerial(dir, texel.a);
      return vec4f(texel.rgb * air.transmittance + air.inscatter, texel.a);
    }
  • dof.wgslvertexfragment ×3 Depth of field from a thin lens: the circle of confusion comes from the focus distance and the aperture, the blur is a single-pass scatter-as-gather along a golden spiral at half resolution (Dennis Gustafsson, "Bokeh depth of field in a single pass", 2018), then blended back over the sharp image where the lens would defocus it. 65 lignes
    Fichier
    dune/prototype-rendu/shaders/dof.wgsl
    Points d’entrée
    vs_fullscreen vertex, prefilter fragment, gather fragment, composite fragment
    Ressources
    4 liaisons
    // PROTOTYPE jetable — depth of field from a thin lens: the circle of confusion comes from the
    // focus distance and the aperture, the blur is a single-pass scatter-as-gather along a golden
    // spiral at half resolution (Dennis Gustafsson, "Bokeh depth of field in a single pass", 2018),
    // then blended back over the sharp image where the lens would defocus it.
    
    @group(0) @binding(8) var litTex: texture_2d<f32>;
    @group(0) @binding(9) var halfTex: texture_2d<f32>;
    @group(0) @binding(10) var blurTex: texture_2d<f32>;
    @group(0) @binding(11) var dofSampler: sampler;
    
    const GOLDEN_ANGLE: f32 = 2.39996323;
    /** Largest blur radius, in half-resolution pixels. */
    const MAX_BLUR: f32 = 12.0;
    const RADIUS_STEP: f32 = 0.85;
    
    struct Fullscreen { @builtin(position) position: vec4f, @location(0) uv: vec2f };
    @vertex fn vs_fullscreen(@builtin(vertex_index) vertex: u32) -> Fullscreen {
      let corner = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0))[vertex];
      return Fullscreen(vec4f(corner, 0.0, 1.0), corner * vec2f(0.5, -0.5) + 0.5);
    }
    
    /** Half resolution: colour, and the signed circle of confusion of the nearest texel. */
    @fragment fn prefilter(@builtin(position) position: vec4f) -> @location(0) vec4f {
      let base = vec2i(position.xy) * 2;
      let last = vec2i(textureDimensions(litTex)) - 1;
      var color = vec3f(0.0);
      var nearest = 1e9;
      for (var k = 0; k < 4; k++) {
        let texel = textureLoad(litTex, min(base + vec2i(k & 1, k >> 1), last), 0);
        // Keep single sparkles from blooming into huge discs.
        color += min(texel.rgb, vec3f(8.0)) * 0.25;
        nearest = min(nearest, texel.a);
      }
      return vec4f(color, clamp(circleOfConfusion(nearest) * 0.5, -MAX_BLUR, MAX_BLUR));
    }
    
    @fragment fn gather(@location(0) uv: vec2f) -> @location(0) vec4f {
      let texel = 1.0 / vec2f(textureDimensions(halfTex));
      let center = textureSampleLevel(halfTex, dofSampler, uv, 0.0);
      let centerSize = abs(center.a);
      var color = center.rgb;
      var total = 1.0;
      var radius = RADIUS_STEP;
      var angle = 0.0;
      loop {
        if (radius >= MAX_BLUR) { break; }
        let tap = textureSampleLevel(halfTex, dofSampler, uv + vec2f(cos(angle), sin(angle)) * texel * radius, 0.0);
        var size = abs(tap.a);
        // Something behind the centre cannot spill over it further than the centre's own blur.
        if (tap.a > center.a) { size = min(size, centerSize * 2.0); }
        let weight = smoothstep(radius - 0.5, radius + 0.5, size);
        color += mix(color / total, tap.rgb, weight);
        total += 1.0;
        radius += RADIUS_STEP / radius;
        angle += GOLDEN_ANGLE;
      }
      return vec4f(color / total, center.a);
    }
    
    @fragment fn composite(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let sharp = textureLoad(litTex, vec2i(position.xy), 0);
      let blurred = textureSampleLevel(blurTex, dofSampler, uv, 0.0);
      let amount = smoothstep(0.8, 2.2, abs(circleOfConfusion(sharp.a)));
      return vec4f(mix(sharp.rgb, blurred.rgb, amount), sharp.a);
    }
  • spray-draw.wgslvertexfragment Each flying particle is a small cloud of grains, drawn as a coverage-weighted dot (never smaller than a pixel, so it cannot flicker away), defocused by its own circle of confusion, lit by the sun where the dunes do not shade it, glowing when seen toward the sun, and flashing when a tumbling grain mirrors it. Soft depth test against the sand behind. 92 lignes
    Fichier
    dune/prototype-rendu/shaders/spray-draw.wgsl
    Points d’entrée
    vs_grain vertex, fs_grain fragment
    Ressources
    2 liaisons
    // PROTOTYPE jetable — each flying particle is a small cloud of grains, drawn as a
    // coverage-weighted dot (never smaller than a pixel, so it cannot flicker away), defocused by
    // its own circle of confusion, lit by the sun where the dunes do not shade it, glowing when
    // seen toward the sun, and flashing when a tumbling grain mirrors it. Soft depth test against
    // the sand behind.
    
    @group(0) @binding(8) var<storage, read> grains: array<Grain>;
    @group(0) @binding(9) var litTex: texture_2d<f32>;
    
    const VEIL_GLOW: f32 = 2.0;
    
    struct GrainOut {
      @builtin(position) clip: vec4f,
      @location(0) color: vec4f,
      @location(1) local: vec2f,
      @location(2) dist: f32,
    };
    
    /** Rodrigues rotation of v around a unit axis. */
    fn rotate(v: vec3f, axis: vec3f, angle: f32) -> vec3f {
      let c = cos(angle);
      let s = sin(angle);
      return v * c + cross(axis, v) * s + axis * dot(axis, v) * (1.0 - c);
    }
    
    @vertex fn vs_grain(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> GrainOut {
      var out: GrainOut;
      out.clip = vec4f(2.0, 2.0, 2.0, 1.0);
      out.color = vec4f(0.0);
      out.local = vec2f(0.0);
      out.dist = 0.0;
      let g = grains[instance];
      if (g.info.w < 0.5) { return out; }
      let world = g.pos.xyz;
      let viewPos = scene.view * vec4f(world, 1.0);
      let depth = -viewPos.z;
      if (depth < 0.3) { return out; }
      let dist = length(world - scene.eye.xyz);
      let pixel = depth * 2.0 * scene.lens.z * scene.viewport.w;
      // A puff of grains ~1 cm across (at the default amount), in pixels².
      let area = 0.7e-4 * g.info.z * scene.haze.y / (pixel * pixel);
      let coc = abs(circleOfConfusion(dist));
      // Never sharper than ~1.6 px: far off, the veil should melt into a haze, not a gravel of dots.
      // The finest dust is drawn wider and fainter still: the glow the grains ride in.
      let fineness = 1.0 - smoothstep(0.1, 0.3, g.info.y);
      let radius = clamp(max(max(sqrt(area / PI), mix(1.6, 4.5, fineness)), coc), 1.6, 24.0);
      let fadeIn = smoothstep(0.0, 0.08, g.pos.w);
      let fadeOut = 1.0 - smoothstep(0.7, 1.0, g.pos.w / g.vel.w);
      // Downwind the veil thins out as it spreads; grains raining onto the lee slope hardly show,
      // nor do the heavier ones: the veil is the fine sand the wind lifts.
      let c = crestFrame(world.xz);
      let above = g.info.w - 1.0;
      let skimming = select(1.0, smoothstep(0.2, 2.0, above), c.s > 0.5);
      let heavy = mix(1.0, 0.5, smoothstep(0.2, 0.6, g.info.y));
      let spread = (1.0 - smoothstep(mix(3.0, 20.0, fineness), mix(18.0, 50.0, fineness), c.s)) * skimming * heavy;
      let alpha = min(area / (PI * radius * radius), 1.0) * fadeIn * fadeOut * spread;
    
      let l = scene.sun.xyz;
      let v = (scene.eye.xyz - world) / dist;
      let lit = sunVisibility(world);
      let sun = sunlightAt(world.y) * lit * SUN_ON_SAND;
      let phase = 0.55 + 2.2 * pow(max(dot(-v, l), 0.0), 5.0);
      let axis = normalize(vec3f(sin(g.info.x * 91.0), cos(g.info.x * 57.0), sin(g.info.x * 23.0 + 1.0)));
      let base = normalize(vec3f(cos(g.info.x * 13.0), sin(g.info.x * 29.0), cos(g.info.x * 7.0 + 2.0)));
      let facet = rotate(base, axis, g.info.x * 40.0 + g.pos.w * (8.0 + 20.0 * g.info.x));
      // Only a few grains are clear enough to flash.
      let glint = pow(max(dot(facet, normalize(l + v)), 0.0), 200.0) * 6.0 * step(0.7, fract(g.info.x * 7.31));
      let albedo = vec3f(0.6, 0.44, 0.3);
      let ambient = 0.5 * (skyIrradiance(v) + skyIrradiance(vec3f(0.0, 1.0, 0.0))) * SKY_ON_SAND;
      // Art direction: the veil is the picture's highlight, as in the concept.
      let color = albedo / PI * (sun * phase * VEIL_GLOW + ambient) + sun * glint * 0.16;
    
      let corner = array<vec2f, 6>(vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0), vec2f(-1.0, -1.0), vec2f(1.0, 1.0), vec2f(-1.0, 1.0))[vertex];
      var clip = scene.proj * viewPos;
      clip.x += corner.x * radius * 2.0 * scene.viewport.z * clip.w;
      clip.y += corner.y * radius * 2.0 * scene.viewport.w * clip.w;
      out.clip = clip;
      // Grains in the dune's shadow are dim specks the eye hardly picks out.
      out.color = vec4f(color, alpha * mix(0.3, 1.0, lit));
      out.local = corner;
      out.dist = dist;
      return out;
    }
    
    @fragment fn fs_grain(in: GrainOut) -> @location(0) vec4f {
      let r2 = dot(in.local, in.local);
      if (r2 >= 1.0) { discard; }
      let behind = textureLoad(litTex, vec2i(in.clip.xy), 0).a;
      let visible = clamp((behind - in.dist) / (0.03 * in.dist + 0.15), 0.0, 1.0);
      let a = min(in.color.a * 3.0 * (1.0 - r2) * (1.0 - r2) * visible, 1.0);
      return vec4f(in.color.rgb * a, a);
    }
  • spray-sim.wgslcompute Sand blown off the crest. Gusts lift grains a few metres upwind of the brink; they hop along the windward back (saltation, a splash at every landing) until the wind carries them over the brink. There the flow separates: coarse grains rain onto the slip face through a slow eddy, fine ones ride the turbulence and drift away as a veil. Each grain relaxes toward the local wind with a Stokes time tau = settling speed / g. 135 lignes
    Fichier
    dune/prototype-rendu/shaders/spray-sim.wgsl
    Points d’entrée
    advance compute @workgroup_size(256)
    Ressources
    1 liaison
    // PROTOTYPE jetable — sand blown off the crest. Gusts lift grains a few metres upwind of the
    // brink; they hop along the windward back (saltation, a splash at every landing) until the
    // wind carries them over the brink. There the flow separates: coarse grains rain onto the
    // slip face through a slow eddy, fine ones ride the turbulence and drift away as a veil.
    // Each grain relaxes toward the local wind with a Stokes time tau = settling speed / g.
    
    @group(0) @binding(8) var<storage, read_write> grains: array<Grain>;
    
    /** Gusts run down the crest with the wind: 0 calm, 1 a strong puff. */
    fn gust(u: f32, t: f32) -> f32 {
      let a = noise(vec2f(u * 0.035 - t * 0.22, t * 0.05 + 3.0));
      let b = noise(vec2f(u * 0.13 - t * 0.7, 9.0 + t * 0.11));
      return smoothstep(0.3, 0.8, a * 0.7 + b * 0.3);
    }
    
    /** Divergence-free eddies: the cross product of two noise gradients, at two scales. */
    fn turbulence(q: vec3f) -> vec3f {
      let a = noised3(q * 0.55).yzw;
      let b = noised3(q * 0.55 + vec3f(31.4, 17.7, 5.3)).yzw;
      let c = noised3(q * 1.9 + vec3f(7.1, 3.3, 11.9)).yzw;
      let d = noised3(q * 1.9 + vec3f(2.9, 41.2, 19.4)).yzw;
      return cross(a, b) + cross(c, d) * 0.12;
    }
    
    /**
     * The grains only ever fly over the hero dune, so they feel its sand alone: the far dune
     * field's noise, evaluated for half a million grains twice a step, was most of the cost.
     */
    fn sandUnder(p: vec2f) -> f32 { return max(heroDune(p), FLOOR); }
    
    fn windAt(p: vec3f, t: f32) -> vec3f {
      let ground = sandUnder(p.xz);
      let above = max(p.y - ground, 0.003);
      let c = crestFrame(p.xz);
      let strength = scene.wind.z * (0.7 + 0.6 * gust(c.u, t));
      // Log profile over the sand (roughness length 1 mm), full speed about 1.5 m up.
      var speed = strength * clamp(log(above / 0.001) / log(1500.0), 0.0, 1.25);
      // The flow speeds up over the brink and rises with the windward slope.
      speed *= 1.0 + 0.3 * exp(-c.s * c.s / 12.0);
      // The windward slope and the crest's speed-up throw the flow upward over the brink.
      // Downwind the shear layer keeps rising before the flow reattaches: the veil climbs.
      var lift = strength * (0.34 * exp(-(c.s - 0.6) * (c.s - 0.6) / 4.0) + 0.18 * smoothstep(0.0, 2.0, c.s) * (1.0 - smoothstep(14.0, 32.0, c.s)));
      // Past the brink it separates: well under a line leaving it at ~14°, a slow eddy turns back.
      if (c.s > 0.0) {
        let eddy = 1.0 - smoothstep(-2.5, -0.3, p.y - (c.height - c.s * 0.25));
        speed = mix(speed, -0.1 * strength, eddy);
        lift = mix(lift, -0.04 * strength, eddy);
      }
      let dir = scene.wind.xy;
      let carried = p - vec3f(dir.x, 0.0, dir.y) * scene.wind.z * t * 0.85;
      // The shear layer over the separation bubble is where the veil churns.
      let gusty = (0.3 + 0.7 * smoothstep(0.0, 1.0, above)) * (1.0 + 0.7 * smoothstep(0.0, 3.0, c.s) * (1.0 - smoothstep(12.0, 30.0, c.s)));
      return vec3f(dir.x * speed, lift, dir.y * speed) + turbulence(carried) * strength * 0.35 * gusty;
    }
    
    @compute @workgroup_size(256)
    fn advance(@builtin(global_invocation_id) id: vec3u) {
      let i = id.x;
      if (i >= u32(scene.sim.y)) { return; }
      let dt = scene.sim.x;
      if (dt <= 0.0) { return; }
      let t = scene.eye.w;
      var g = grains[i];
      var seed = pcg(i * 9781u + u32(scene.sim.z) * 6271u + 17u);
      if (g.info.w < 0.5) {
        // Resting: wait for a gust to lift it somewhere along the crest.
        // Mostly near the summit: along the top of the near arm, and down the far one.
        let pick = rand(seed);
        seed = pcg(seed);
        let upwind = pow(rand(seed), 3.0) * 2.5;
        seed = pcg(seed);
        var xz = crestPoint(mix(16.0, SUMMIT_Z, pow(pick / 0.85, 0.45)), -upwind);
        var u = mix(16.0, SUMMIT_Z, pow(pick / 0.85, 0.45));
        if (pick > 0.85) {
          u = SUMMIT_Z + (pick - 0.85) / 0.15 * 12.0;
          xz = farPoint((pick - 0.85) / 0.15 * 12.0, -upwind);
        }
        let chance = gust(u, t);
        if (rand(seed) > chance * chance * 0.2) { return; }
        seed = pcg(seed + 1u);
        let kind = rand(seed);
        seed = pcg(seed);
        var settle = 0.35 + 0.35 * rand(seed);
        var life = 3.5;
        if (kind < 0.22) {
          settle = 0.9 + 0.6 * rand(seed + 3u);
          life = 2.2;
        } else if (kind > 0.55) {
          settle = 0.05 + 0.13 * rand(seed + 5u);
          life = 5.0;
        }
        seed = pcg(seed + 7u);
        let dir = scene.wind.xy;
        let launch = scene.wind.z * (0.25 + 0.35 * rand(seed));
        seed = pcg(seed);
        let rise = 0.4 + 1.6 * rand(seed);
        seed = pcg(seed);
        g.pos = vec4f(xz.x, sandUnder(xz) + 0.004, xz.y, 0.0);
        g.vel = vec4f(dir.x * launch, rise, dir.y * launch, life * (0.6 + 0.8 * rand(seed)));
        g.info = vec4f(rand(seed + 11u), settle, 0.6 + 0.8 * rand(seed + 13u), 1.0);
        grains[i] = g;
        return;
      }
      // Flying: exact relaxation toward the wind while settling at terminal speed.
      let settle = g.info.y;
      let tau = max(settle / 9.81, 0.004);
      let air = windAt(g.pos.xyz, t) - vec3f(0.0, settle, 0.0);
      let relax = exp(-dt / tau);
      let v0 = g.vel.xyz;
      var v = air + (v0 - air) * relax;
      var p = g.pos.xyz + air * dt + (v0 - air) * tau * (1.0 - relax);
      let age = g.pos.w + dt;
      var alive = age < g.vel.w;
      let ground = sandUnder(p.xz);
      if (p.y < ground) {
        let c = crestFrame(p.xz);
        if (c.s < -0.2 && rand(seed) < 0.8) {
          // Landing on the windward back splashes it up again.
          p.y = ground + 0.002;
          v = vec3f(v.x * 0.5, 0.35 + 0.9 * rand(seed + 1u), v.z * 0.5);
        } else {
          alive = false;
        }
      }
      if (!alive) {
        g.info.w = 0.0;
        grains[i] = g;
        return;
      }
      g.pos = vec4f(p, age);
      g.vel = vec4f(v, g.vel.w);
      // Flying grains keep 1 + their height above the sand in info.w, for the drawing.
      g.info.w = 1.0 + clamp(p.y - ground, 0.0, 100.0);
      grains[i] = g;
    }
  • present.wgslfragment The picture: exposure, AgX tone mapping (Troy Sobotka's curve, as fitted by Benjamin Wrensch), a soft vignette and film grain; or a diagnostic view, or the concept artwork beside the render. 84 lignes
    Fichier
    dune/prototype-rendu/shaders/present.wgsl
    Points d’entrée
    fs_present fragment
    Ressources
    3 liaisons
    // PROTOTYPE jetable — the picture: exposure, AgX tone mapping (Troy Sobotka's curve, as fitted
    // by Benjamin Wrensch), a soft vignette and film grain; or a diagnostic view, or the concept
    // artwork beside the render.
    
    @group(0) @binding(8) var finalTex: texture_2d<f32>;
    @group(0) @binding(9) var conceptTex: texture_2d<f32>;
    @group(0) @binding(10) var presentSampler: sampler;
    
    const WHITE_BALANCE = vec3f(0.9, 1.0, 1.18);
    
    fn agxContrast(x: vec3f) -> vec3f {
      let x2 = x * x;
      let x4 = x2 * x2;
      return 15.5 * x4 * x2 - 40.14 * x4 * x + 31.96 * x4 - 6.868 * x2 * x + 0.4298 * x2 + 0.1191 * x - 0.00232;
    }
    
    fn agx(color: vec3f) -> vec3f {
      let inset = mat3x3f(
        0.842479062253094, 0.0423282422610123, 0.0423756549057051,
        0.0784335999999992, 0.878468636469772, 0.0784336,
        0.0792237451477643, 0.0791661274605434, 0.879142973793104);
      let outset = mat3x3f(
        1.19687900512017, -0.0528968517574562, -0.0529716355144438,
        -0.0980208811401368, 1.15190312990417, -0.0980434501171241,
        -0.0990297440797205, -0.0989611768448433, 1.15107367264116);
      let minEv = -12.47393;
      let maxEv = 4.026069;
      var v = inset * max(color, vec3f(1e-10));
      v = clamp(log2(v), vec3f(minEv), vec3f(maxEv));
      v = (v - minEv) / (maxEv - minEv);
      v = agxContrast(v);
      // A touch more saturation in the mids, like a warm print.
      let luma = dot(v, vec3f(0.2126, 0.7152, 0.0722));
      v = luma + 1.25 * (v - luma);
      // AgX leaves display-encoded values: through the outset, then back to linear for toSrgb.
      return clamp(pow(max(outset * v, vec3f(0.0)), vec3f(2.2)), vec3f(0.0), vec3f(1.0));
    }
    
    fn toSrgb(c: vec3f) -> vec3f {
      return select(1.055 * pow(c, vec3f(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3f(0.0031308));
    }
    
    fn grain(pixel: vec2f, frame: f32) -> f32 {
      return fract(52.9829189 * fract(dot(pixel + frame * vec2f(5.588238, 5.588238), vec2f(0.06711056, 0.00583715))));
    }
    
    @fragment fn fs_present(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
      let mode = viewMode();
      if (mode == VIEW_COMPARE && uv.x < scene.haze.z) {
        // The concept, cover-fitted to the canvas.
        let size = vec2f(textureDimensions(conceptTex));
        let canvasAspect = scene.viewport.x / scene.viewport.y;
        let imageAspect = size.x / size.y;
        var st = uv - 0.5;
        if (canvasAspect > imageAspect) { st.y *= imageAspect / canvasAspect; } else { st.x *= canvasAspect / imageAspect; }
        let artwork = textureSampleLevel(conceptTex, presentSampler, st + 0.5, 0.0).rgb;
        let edge = 1.0 - smoothstep(0.0, 1.5, abs(position.x - scene.haze.z * scene.viewport.x));
        return vec4f(mix(artwork, vec3f(1.0), edge * 0.8), 1.0);
      }
      let texel = textureLoad(finalTex, vec2i(position.xy), 0);
      if (mode == VIEW_DEPTH) {
        // Circle of confusion: blue in front of the focus, red behind, white where it is sharp.
        let coc = circleOfConfusion(texel.a);
        let sharp = 1.0 - smoothstep(0.0, 1.5, abs(coc));
        let amount = clamp(abs(coc) / 12.0, 0.0, 1.0);
        let tint = select(vec3f(0.95, 0.35, 0.2), vec3f(0.25, 0.5, 1.0), coc < 0.0) * (0.25 + 0.75 * amount);
        let base = toSrgb(agx(texel.rgb * scene.look.x)) * 0.25;
        return vec4f(base + mix(tint, vec3f(1.0), sharp) * 0.75, 1.0);
      }
      // White balance of a camera set near 4300 K: the low sun reads golden, not red.
      var color = texel.rgb * WHITE_BALANCE * scene.look.x;
      let centered = uv - 0.5;
      color *= 1.0 - 0.28 * smoothstep(0.15, 0.85, dot(centered, centered) * 2.2);
      var display = toSrgb(agx(color));
      // Film grain, stronger in the mids, and dithering against banding in the sky.
      let g = grain(position.xy, fract(scene.eye.w * 7.0) * 64.0) + grain(position.yx + 17.0, fract(scene.eye.w * 3.0) * 64.0) - 1.0;
      let mids = 4.0 * dot(display, vec3f(0.333)) * (1.0 - dot(display, vec3f(0.333)));
      display += g * (1.2 / 255.0 + 0.018 * mids);
      if (mode == VIEW_COMPARE) {
        let edge = 1.0 - smoothstep(0.0, 1.5, abs(position.x - scene.haze.z * scene.viewport.x));
        display = mix(display, vec3f(1.0), edge * 0.8);
      }
      return vec4f(display, 1.0);
    }
  • trail.wgslcompute A trace drawn in the sand, and the wind taking it back. The trace is a height field over the sand near the observer: x its height offset (m), y how much of the ripples it wiped out. A stroke carves a groove and heaps the sand it pushes aside into two low berms. Then, each step, the walls slump a little, the groove silts up in irregular patches over a minute or two, and the ripples grow back over it. No wind, no erasing. (Moving the trace downwind by sub-texel steps smeared it out within seconds: left out.) 98 lignes
    Fichier
    dune/prototype-rendu/shaders/trail.wgsl
    Points d’entrée
    evolve compute @workgroup_size(8,8)
    Ressources
    8 liaisons
    // PROTOTYPE jetable — a trace drawn in the sand, and the wind taking it back. The trace is a
    // height field over the sand near the observer: x its height offset (m), y how much of the
    // ripples it wiped out. A stroke carves a groove and heaps the sand it pushes aside into two
    // low berms. Then, each step, the walls slump a little, the groove silts up in irregular
    // patches over a minute or two, and the ripples grow back over it. No wind, no erasing.
    // (Moving the trace downwind by sub-texel steps smeared it out within seconds: left out.)
    
    @group(0) @binding(8) var trailIn: texture_2d<f32>;
    @group(0) @binding(9) var trailOut: texture_storage_2d<rgba16float, write>;
    @group(0) @binding(10) var<storage, read> strokes: array<vec4f>;
    @group(0) @binding(11) var<uniform> stroke: vec4f;       // x: segment count, y: groove half-width (m), z: groove depth (m), w: wipe (1)
    @group(0) @binding(12) var<uniform> strokeBounds: vec4f; // min x, min z, max x, max z of this step's segments (m)
    @group(0) @binding(13) var<uniform> erosion: vec4f;      // x: erasing speed factor, y: time since the last step (s)
    @group(0) @binding(14) var<storage, read> steps: array<vec4f>; // footprints: x, z (m), heading (unit x, z)
    @group(0) @binding(15) var<uniform> stepShape: vec4f;    // x: footprint count, y: depth (m), z: half length (m), w: half width (m)
    
    /**
     * One footprint, worn by the wind: an oval hollow along the walker's heading, deeper at the
     * heel and the toe, its outline ragged, sand heaped up here and there around it.
     */
    fn footprint(p: vec2f, foot: vec4f) -> vec2f {
      let d = p - foot.xy;
      let along = dot(d, foot.zw) / stepShape.z;
      let across = dot(d, vec2f(-foot.w, foot.z)) / stepShape.w;
      let r = length(vec2f(along, across));
      let ragged = 1.0 + 0.3 * (noise(d * 38.0 + foot.xy * 7.0) - 0.5);
      let x = r / ragged;
      if (x > 1.8) { return vec2f(0.0, 0.0); }
      let bowl = max(1.0 - x * x, 0.0);
      let heelAndToe = 0.75 + 0.35 * smoothstep(0.3, 0.8, abs(along));
      // Sand pushed out of the print heaps up around it in lumps.
      let rim = 0.55 * exp(-pow((x - 1.15) / 0.4, 2.0)) * (0.3 + noise(d * 22.0 + 4.0));
      let height = stepShape.y * (-pow(bowl, 1.3) * heelAndToe + rim);
      return vec2f(height, 1.0 - smoothstep(1.05, 1.5, x));
    }
    
    fn fetch(i: vec2i) -> vec4f {
      let last = i32(scene.trail.w) - 1;
      return textureLoad(trailIn, clamp(i, vec2i(0), vec2i(last)), 0);
    }
    
    fn segmentDistance(p: vec2f, a: vec2f, b: vec2f) -> f32 {
      let ab = b - a;
      let t = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-10), 0.0, 1.0);
      return length(p - a - ab * t);
    }
    
    @compute @workgroup_size(8, 8)
    fn evolve(@builtin(global_invocation_id) id: vec3u) {
      let texels = u32(scene.trail.w);
      if (id.x >= texels || id.y >= texels) { return; }
      let texel = scene.trail.z / scene.trail.w;
      let p = scene.trail.xy + (vec2f(id.xy) + 0.5) * texel;
      let dt = erosion.y;
      let wind = scene.wind.z * erosion.x;
      let i = vec2i(id.xy);
      var here = fetch(i);
      let around = (fetch(i + vec2i(1, 0)) + fetch(i - vec2i(1, 0)) + fetch(i + vec2i(0, 1)) + fetch(i - vec2i(0, 1))) * 0.25;
      // Patchy, like the gusts and the sand they carry: some stretches go long before others.
      let patchy = 0.3 + 1.4 * noise(p * 1.7 + scene.wind.xy * scene.eye.w * 0.06);
      let rate = wind / 8.0 * patchy;
      here.x = mix(here.x, around.x, clamp(dt * 0.25 * rate, 0.0, 0.5));
      here.x *= exp(-dt * rate / 70.0);
      here.y *= exp(-dt * rate / 90.0);
      if (stroke.w > 0.5) { here = vec4f(0.0); }
      if (stroke.x > 0.5 && all(p >= strokeBounds.xy) && all(p <= strokeBounds.zw)) {
        // Sand does not hold a clean edge: the groove's width wavers by a few millimetres.
        let width = stroke.y * (0.85 + 0.3 * noise(p * 11.0 + 1.3));
        // A hand does not press evenly: the groove deepens and eases along the way.
        let depth = stroke.z * (0.65 + 0.6 * noise(p * 5.0 + 3.7));
        // Distance to the whole stroke first: carving segment by segment let one segment's berm
        // cover the next one's groove at every joint.
        var nearest = 1e9;
        for (var k = 0u; k < u32(stroke.x); k++) {
          let segment = strokes[k];
          nearest = min(nearest, segmentDistance(p, segment.xy, segment.zw));
        }
        let x = nearest / width;
        if (x < 3.0) {
          // The groove, and the sand it pushed aside heaped into berms on both sides.
          let profile = -depth * exp(-2.2 * x * x) + 0.42 * depth * exp(-pow((x - 1.45) / 0.5, 2.0));
          // Berms never refill a groove already dug (by this stroke a frame ago, or another one).
          let heaped = select(here.x, max(here.x, profile), here.x > -2e-4);
          here.x = select(heaped, min(here.x, profile), profile < 0.0);
          // Ripples are wiped out under the groove and the berms, and blurred just beyond.
          here.y = max(here.y, 1.0 - smoothstep(2.2, 2.9, x));
        }
      }
      if (stepShape.x > 0.5 && all(p >= strokeBounds.xy) && all(p <= strokeBounds.zw)) {
        for (var k = 0u; k < u32(stepShape.x); k++) {
          let mark = footprint(p, steps[k]);
          let heaped = select(here.x, max(here.x, mark.x), here.x > -2e-4);
          here.x = select(heaped, min(here.x, mark.x), mark.x < 0.0);
          here.y = max(here.y, mark.y);
        }
      }
      textureStore(trailOut, id.xy, here);
    }