Explorar o código

feat(renderer): dreamy, fully antialiased look

- Dream filter on the camera: the world is rendered at 2x the screen
  density and scaled down (supersampling smooths every edge, vector
  characters included), with a bloom on bright areas, a light soft-focus
  veil and a pastel grade (slight desaturation, lilac-tinted shadows).
- Kitchen background blurred at load (depth of field) and a little hazy.
- Sugar dust drifting behind and in front of the cake.
- Terrain: material boundaries and lighting interpolated between pixel
  centers, contour smoothed with a 1-2-1 pass, no more blocky ink noise.
- Rounded cream drips (gateau-etages regenerated), soft round crumbs.

No new dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman hai 6 días
pai
achega
2c55542cc6

BIN=BIN
assets/maps/gateau-etages/terrain.png


BIN=BIN
assets/maps/gateau-etages/thumb.png


+ 2 - 1
readme.md

@@ -259,6 +259,7 @@ Une carte invalide est **rejetée avec un message explicite**. On ne la charge j
 - **Son :** mastication, « splotch », « crrrac » de biscuit, prouts variés. Musique : chanson d'anniversaire à la flûte à bec qui accélère avec la montée de la crème.
 - Placeholders autorisés pour le développement : formes géométriques colorées. La logique ne dépend jamais des assets finaux.
 - **Netteté (rendu client) :** le renderer suit la densité de l'écran (`devicePixelRatio`, plafonnée à `MAX_RESOLUTION`). Le terrain est dessiné en sur-échantillonnage (`TERRAIN_SUPERSAMPLE` texels par pixel) à partir d'un champ de distance signée calculé autour de chaque morceau : les bords sont des courbes lissées et anticrénelées, jamais l'escalier du bitmap. Le bitmap et les ops restent au pixel près (seul l'affichage change). Les fonds sont générés en 2× et anticrénelés par `npm run gen:maps` ; le client déduit leur résolution de leur taille.
+- **Ambiance onirique** (`renderer/fx/Dream.ts`, réglages `DREAM_*` dans `renderer/config.ts`) : le monde est rendu en 2× dans un filtre puis réduit (super-échantillonnage : tous les bords sont lissés, personnages compris). Le filtre ajoute un halo lumineux sur les zones claires, un léger voile flou, des couleurs pastel légèrement désaturées et des ombres teintées de lilas. Le fond de cuisine est flou (profondeur de champ, `BACKGROUND_BLUR`), et de la poussière de sucre flotte devant et derrière le gâteau. Les transitions entre matériaux et les dégradés de lumière du terrain sont interpolés, et les coulures de crème sont des gouttes arrondies.
 - **Personnages :** une boîte à outils commune (`renderer/characters/painters.ts`) leur donne du volume (ombre propre, reflet, éclat, hachures au crayon), des yeux expressifs (sourcils selon l'action, clignements, spirales quand assommé), des joues roses, une ombre de contact au sol et une inclinaison dans la course.
 ---
  
@@ -342,7 +343,7 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
     - **Le Chat :** sa poussée déplace blocs, objets et joueurs assommés à pleine force (les autres un peu). Il s'arrête au hasard pour se lécher la patte et ignore alors les commandes.
     - **Magicien Raté :** gelée (génoise, biscuit et chantilly deviennent gelée), échange de place avec un joueur au hasard, lapin géant en génoise qui tombe du ciel, ou « pfft ». Le poids du « pfft » augmente avec son classement dans la manche.
     - Les signatures (prout, jet, sortie de terre, sorts, léchouille) passent par un événement `cue` commun, prêt pour l'audio.
-  - **DA, première passe finale :** terrain et fonds nets à toutes les résolutions, personnages en volume et plus expressifs (§7).
+  - **DA, première passe finale :** terrain et fonds nets à toutes les résolutions, personnages en volume et plus expressifs, ambiance onirique (§7).
 - **Reste :** la suite de la DA (sprites dessinés, particules par matériau), l'audio, l'éditeur de cartes et l'installeur Windows.
 ---
  

+ 7 - 2
scripts/gen-maps.ts

@@ -47,8 +47,13 @@ class Canvas {
     for (let x = x0 - 6; x < x1 + 6; x++) {
       const wave = Math.round(6 + 4 * Math.sin(x / 23));
       for (let y = top - wave; y < top + 12; y++) this.put(x, y, CREAM);
-      const drip = (x * 7919) % 97 < 6 ? 18 + ((x * 31) % 22) : 0;
-      if (x >= x0 && x < x1) for (let y = top + 12; y < top + 12 + drip; y++) this.put(x, y, CREAM);
+    }
+    // Drips: rounded drops of cream hanging from the frosting.
+    for (let x = x0 + 24; x < x1 - 24; x += 46 + ((x * 31) % 53)) {
+      const half = 3 + ((x * 13) % 3);
+      const len = 12 + ((x * 17) % 26);
+      this.rect(x - half, top + 6, x + half + 1, top + 12 + len, CREAM);
+      this.disc(x, top + 12 + len, half + 1.5, CREAM);
     }
     for (let y = top; y < top + 60; y++) {
       const len = Math.max(0, 6 - Math.floor((y - top) / 10));

+ 17 - 0
src/renderer/config.ts

@@ -24,6 +24,23 @@ export const TERRAIN_SUPERSAMPLE = 2;
 export const TERRAIN_AA = 0.7;
 /** Highest renderer resolution (device pixels per CSS pixel), to keep 60 FPS on 4K screens. */
 export const MAX_RESOLUTION = 2;
+/** Depth-of-field blur of the kitchen background (map px). */
+export const BACKGROUND_BLUR = 1.6;
+
+// Dreamy look (fx/Dream.ts): soft-focus glow, pastel grade, sugar dust.
+/** Bloom: how much bright areas bleed light (0..1). */
+export const DREAM_GLOW = 0.35;
+/** Soft focus: veil of blur over everything (0..1). Too high and the game gets hard to read. */
+export const DREAM_SOFT = 0.22;
+/** Blur radius of the glow, in screen pixels. */
+export const DREAM_RADIUS = 7;
+export const DREAM_DESAT = 0.08;
+/** How much the shadows are lifted toward DREAM_TINT (0..1). */
+export const DREAM_LIFT = 0.07;
+export const DREAM_TINT = 0xd8b8e8;
+export const DUST_COUNT = 70;
+/** The world is rendered at this many times the screen density, then scaled down (antialiasing). */
+export const DREAM_SUPERSAMPLE = 2;
 
 export const SHAKE_PER_PIXEL = 0.004;
 export const SHAKE_MAX = 12;

+ 149 - 0
src/renderer/fx/Dream.ts

@@ -0,0 +1,149 @@
+import { CanvasSource, Container, defaultFilterVert, Filter, GlProgram, Sprite, Texture, UniformGroup } from 'pixi.js';
+import { DREAM_DESAT, DREAM_GLOW, DREAM_LIFT, DREAM_RADIUS, DREAM_SOFT, DREAM_TINT, DUST_COUNT } from '../config';
+
+// The dreamy look: a soft-focus glow over the whole world, a pastel grade, and sugar dust
+// floating in the air. Purely cosmetic.
+
+const fragment = /* glsl */ `
+in vec2 vTextureCoord;
+out vec4 finalColor;
+
+uniform sampler2D uTexture;
+uniform highp vec4 uInputSize;
+uniform highp vec4 uInputClamp;
+uniform float uGlow;
+uniform float uSoft;
+uniform float uRadius;
+uniform float uDesat;
+uniform float uLift;
+uniform vec3 uTint;
+
+void main() {
+  vec4 base = texture(uTexture, vTextureCoord);
+  // Wide soft blur: 16 taps on a golden-angle spiral.
+  vec4 sum = vec4(0.0);
+  float wsum = 0.0;
+  for (int i = 0; i < 16; i++) {
+    float fi = float(i);
+    float r = sqrt((fi + 0.5) / 16.0) * uRadius;
+    float a = fi * 2.39996;
+    vec2 uv = clamp(vTextureCoord + vec2(cos(a), sin(a)) * r * uInputSize.zw, uInputClamp.xy, uInputClamp.zw);
+    float w = 1.0 - fi / 24.0;
+    sum += texture(uTexture, uv) * w;
+    wsum += w;
+  }
+  vec3 blur = (sum / wsum).rgb;
+  vec3 c = base.rgb;
+  // Bloom: bright areas bleed light around them (screen blend of the blur).
+  float lum = dot(blur, vec3(0.299, 0.587, 0.114));
+  vec3 glow = 1.0 - (1.0 - c) * (1.0 - blur * smoothstep(0.45, 1.0, lum));
+  c = mix(c, glow, uGlow);
+  // Soft focus: a veil of the blur everywhere, so every edge melts a little.
+  c = mix(c, max(c, blur), uSoft);
+  // Pastel grade: a little less saturated, shadows lifted toward a warm lilac.
+  float l = dot(c, vec3(0.299, 0.587, 0.114));
+  c = mix(c, vec3(l), uDesat);
+  c = c * (1.0 - uLift) + uTint * uLift * base.a;
+  finalColor = vec4(c, base.a);
+}
+`;
+
+/**
+ * The world is rendered into the filter at `resolution` (screen density × DREAM_SUPERSAMPLE) and
+ * scaled back down: supersampling smooths every edge, vector shapes included.
+ */
+export function makeDreamFilter(resolution: number): Filter {
+  const t = DREAM_TINT;
+  return new Filter({
+    glProgram: GlProgram.from({ vertex: defaultFilterVert, fragment, name: 'dream-filter' }),
+    resources: {
+      dreamUniforms: new UniformGroup({
+        uGlow: { value: DREAM_GLOW, type: 'f32' },
+        uSoft: { value: DREAM_SOFT, type: 'f32' },
+        uRadius: { value: DREAM_RADIUS * resolution, type: 'f32' },
+        uDesat: { value: DREAM_DESAT, type: 'f32' },
+        uLift: { value: DREAM_LIFT, type: 'f32' },
+        uTint: { value: new Float32Array([(t >> 16) / 255, ((t >> 8) & 0xff) / 255, (t & 0xff) / 255]), type: 'vec3<f32>' },
+      }),
+    },
+    resolution,
+    antialias: 'on',
+  });
+}
+
+/** A soft round glow, white in the middle. */
+function glowTexture(): Texture {
+  const size = 64;
+  const canvas = document.createElement('canvas');
+  canvas.width = size;
+  canvas.height = size;
+  const ctx = canvas.getContext('2d')!;
+  const grad = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
+  grad.addColorStop(0, 'rgba(255,255,255,1)');
+  grad.addColorStop(0.25, 'rgba(255,250,235,0.55)');
+  grad.addColorStop(1, 'rgba(255,240,220,0)');
+  ctx.fillStyle = grad;
+  ctx.fillRect(0, 0, size, size);
+  return new Texture({ source: new CanvasSource({ resource: canvas }) });
+}
+
+interface Mote {
+  sprite: Sprite;
+  x: number;
+  y: number;
+  phase: number;
+  speed: number;
+  size: number;
+}
+
+const DUST_COLORS = [0xffffff, 0xfff1b8, 0xffd1e0, 0xd8ecff, 0xe8dcff];
+
+/**
+ * Sugar dust drifting up through the air, twinkling. Two layers: small motes behind the
+ * terrain, bigger out-of-focus ones in front. Uses Math.random: purely visual.
+ */
+export class SugarDust {
+  readonly back = new Container();
+  readonly front = new Container();
+  private readonly motes: Mote[] = [];
+  private time = 0;
+  private width = 2048;
+  private height = 1024;
+
+  constructor() {
+    const texture = glowTexture();
+    for (let i = 0; i < DUST_COUNT; i++) {
+      const inFront = i % 4 === 0;
+      const sprite = new Sprite(texture);
+      sprite.anchor.set(0.5);
+      sprite.blendMode = 'add';
+      sprite.tint = DUST_COLORS[i % DUST_COLORS.length];
+      const size = inFront ? 18 + Math.random() * 26 : 5 + Math.random() * 9;
+      sprite.width = size;
+      sprite.height = size;
+      (inFront ? this.front : this.back).addChild(sprite);
+      this.motes.push({ sprite, x: Math.random(), y: Math.random(), phase: Math.random() * Math.PI * 2, speed: 6 + Math.random() * 14, size });
+    }
+  }
+
+  /** Spreads the dust over a map of this size. */
+  setArea(width: number, height: number): void {
+    this.width = width;
+    this.height = height;
+  }
+
+  update(dt: number): void {
+    this.time += dt;
+    for (const m of this.motes) {
+      m.y -= (m.speed * dt) / this.height;
+      if (m.y < -0.05) {
+        m.y = 1.05;
+        m.x = Math.random();
+      }
+      const sway = Math.sin(this.time * 0.6 + m.phase) * 18;
+      m.sprite.position.set(m.x * this.width + sway, m.y * this.height);
+      const twinkle = 0.5 + 0.5 * Math.sin(this.time * 2.2 + m.phase * 3);
+      m.sprite.alpha = (m.size > 15 ? 0.16 : 0.35) + twinkle * (m.size > 15 ? 0.12 : 0.45);
+    }
+  }
+}

+ 4 - 7
src/renderer/fx/Particles.ts

@@ -86,14 +86,11 @@ export class Particles {
         g.circle(p.x, p.y, p.size * (1.6 - k * 0.6)).fill({ color: p.color, alpha: k * 0.8 }).stroke({ width: 2, color: OUTLINE_COLOR, alpha: k * 0.5 });
         continue;
       }
+      // Crumbs: soft rounded blobs with a light top and a faded outline.
       const s = p.size * (0.5 + 0.5 * k);
-      const pts: number[] = [];
-      for (let i = 0; i < 4; i++) {
-        const a = p.angle + (i * Math.PI) / 2 + (i % 2) * 0.4;
-        const rr = s * (i % 2 ? 0.75 : 1);
-        pts.push(p.x + Math.cos(a) * rr, p.y + Math.sin(a) * rr);
-      }
-      g.poly(pts).fill(p.color).stroke({ width: 1.5, color: OUTLINE_COLOR, alpha: Math.min(1, k * 1.5) });
+      const a = Math.min(1, k * 1.5);
+      g.ellipse(p.x, p.y, s, s * 0.78).fill({ color: p.color, alpha: a }).stroke({ width: 1.2, color: OUTLINE_COLOR, alpha: a * 0.55 });
+      g.circle(p.x - s * 0.3, p.y - s * 0.3, s * 0.32).fill({ color: 0xffffff, alpha: a * 0.45 });
     }
   }
 }

+ 22 - 3
src/renderer/main.ts

@@ -1,5 +1,5 @@
 import 'pixi.js/unsafe-eval'; // Pixi without eval, required by our Content-Security-Policy
-import { Application, BlurFilter, CanvasSource, Container, ImageSource, Sprite, Texture } from 'pixi.js';
+import { Application, BlurFilter, CanvasSource, Container, Sprite, Texture } from 'pixi.js';
 import { Anim, PLAYABLE_CHARACTER_IDS, parseCharacterStats } from '../shared/characters';
 import { DEFAULT_CHARACTER_ID, INTERPOLATION_DELAY_MS, PROTOCOL_VERSION } from '../shared/constants';
 import { buildTerrain, parseMapDefinition } from '../shared/maps/loader';
@@ -17,6 +17,8 @@ import {
   EXPLOSION_SHAKE,
   FREEZE_FRAME_MS,
   HIT_SHAKE,
+  DREAM_SUPERSAMPLE,
+  BACKGROUND_BLUR,
   MAX_RESOLUTION,
   POISON_BLUR,
   INPUT_SEND_HZ,
@@ -34,6 +36,7 @@ import {
 import { ChunkView } from './entities/ChunkView';
 import { PlayerView } from './entities/PlayerView';
 import { CherryView, CloudsView, CustardView, ItemsView, SpoonsView } from './entities/RoundViews';
+import { makeDreamFilter, SugarDust } from './fx/Dream';
 import { Particles } from './fx/Particles';
 import { Popups } from './fx/Popups';
 import { fr } from './i18n/fr';
@@ -72,7 +75,15 @@ async function loadWorld(mapId: string, materials: MaterialRegistry): Promise<Wo
   const bitmap = await createImageBitmap(new Blob([files.background as BlobPart], { type: 'image/png' }));
   // Backgrounds may be drawn at a higher resolution than the map (gen-maps draws them at 2×).
   const resolution = Math.max(1, bitmap.width / terrain.width);
-  const background = new Sprite(new Texture({ source: new ImageSource({ resource: bitmap, resolution, scaleMode: 'linear' }) }));
+  // Depth of field: the kitchen behind is out of focus and a little hazy, the cake stands out.
+  const canvas = document.createElement('canvas');
+  canvas.width = bitmap.width;
+  canvas.height = bitmap.height;
+  const ctx = canvas.getContext('2d')!;
+  ctx.filter = `blur(${BACKGROUND_BLUR * resolution}px) saturate(0.85) brightness(1.05)`;
+  ctx.drawImage(bitmap, 0, 0);
+  bitmap.close();
+  const background = new Sprite(new Texture({ source: new CanvasSource({ resource: canvas, resolution, scaleMode: 'linear' }) }));
   return { materials, terrain, view: new TerrainView(terrain, materials), background, lastSeq: 0 };
 }
 
@@ -138,6 +149,9 @@ async function start(): Promise<void> {
   const camera = new Container();
   const worldRoot = new Container();
   camera.addChild(worldRoot);
+  // Dreamy look over the whole world (the poison blur lives on worldRoot).
+  camera.filters = [makeDreamFilter(app.renderer.resolution * DREAM_SUPERSAMPLE)];
+  const dust = new SugarDust();
   const vignette = makeVignette();
   app.stage.addChild(camera, vignette);
   const particles = new Particles();
@@ -154,6 +168,7 @@ async function start(): Promise<void> {
   // Map-independent layers always sit above the map ones (background, terrain).
   // The custard covers players who sink into it; the spoon flicks out of it.
   worldRoot.addChild(
+    dust.back,
     chunksLayer,
     itemsView.container,
     playersLayer,
@@ -162,6 +177,7 @@ async function start(): Promise<void> {
     custardView.graphics,
     spoonsView.container,
     particles.graphics,
+    dust.front,
     popups.container,
   );
   const hud = new RoundHud();
@@ -301,7 +317,9 @@ async function start(): Promise<void> {
         if (gen !== generation) return; // superseded by a newer start
         world = w;
         worldRoot.addChildAt(w.background, 0);
-        worldRoot.addChildAt(w.view.container, 1);
+        // The dust drifts between the background and the cake.
+        worldRoot.addChildAt(w.view.container, 2);
+        dust.setArea(w.terrain.width, w.terrain.height);
         for (const { seq, ev } of history) applyServerEvent(seq, ev, false);
         for (const c of chunks) addChunk(c.id, rleDecode(c.mask, c.w * c.h), c.w, c.h, c.state);
         const live = pendingEvents;
@@ -509,6 +527,7 @@ async function start(): Promise<void> {
 
     world.view.update();
     particles.update(dt);
+    dust.update(dt);
     popups.update(dt);
     popupCooldown -= dt;
 

+ 82 - 55
src/renderer/terrain/TerrainView.ts

@@ -19,8 +19,8 @@ import {
   UNDERSIDE_DARKEN,
   UNDERSIDE_DEPTH,
 } from '../config';
-import { sampleBilinear, signedDistance } from './distance';
-import { hash2, makeThicknessTile, makeTile, scaledTileIndex, tileIndex } from './textures';
+import { sampleBilinear, signedDistance, smooth121 } from './distance';
+import { makeThicknessTile, makeTile, scaledTileIndex, tileIndex } from './textures';
 
 interface ChunkSprite {
   ctx: CanvasRenderingContext2D;
@@ -72,6 +72,10 @@ export class TerrainView {
   private readonly shadeK = new Float32Array(CHUNK_SIZE * CHUNK_SIZE);
   private readonly shadeW = new Float32Array(CHUNK_SIZE * CHUNK_SIZE);
   private readonly cavityK = new Float32Array(CHUNK_SIZE * CHUNK_SIZE);
+  /** Output of blend(). */
+  private br = 0;
+  private bg = 0;
+  private bb = 0;
 
   constructor(
     private readonly bitmap: TerrainBitmap,
@@ -206,6 +210,8 @@ export class TerrainView {
       return y * width + x;
     };
     signedDistance((i) => data[at(i)] !== MATERIAL_EMPTY, WIN, WIN, this.sd, M);
+    // Irons out the little waves a pixel staircase leaves in the contour.
+    smooth121(this.sd, WIN, WIN);
     for (let i = 0; i < WIN * WIN; i++) this.orig[i] = original[at(i)] !== MATERIAL_EMPTY ? 1 : 0;
 
     // Per-pixel lighting from column scans: rim light on top faces, darker undersides, cavity shade.
@@ -240,16 +246,12 @@ export class TerrainView {
     const texH = (y1 - y0) * S;
     for (let ty = 0; ty < texH; ty++) {
       const py = ty / S;
-      const y = y0 + Math.floor(py);
       // Window coordinates of the texel center.
       const v = py + 0.5 / S + M;
       for (let tx = 0; tx < texW; tx++) {
         const px = tx / S;
-        const x = x0 + Math.floor(px);
         const u = px + 0.5 / S + M;
-        const p = (y - y0) * CHUNK_SIZE + (x - x0);
         const o = (ty * TEX + tx) * 4;
-        const i = y * width + x;
 
         // Background layer: the inside of the cake where it was dug, and the drop shadow.
         let bgR = 0;
@@ -258,17 +260,12 @@ export class TerrainView {
         let bgA = 0;
         const cav = Math.min(1, Math.max(0, (sampleBilinear(origCov, WIN, WIN, u, v) - 0.5) / TERRAIN_AA + 0.5));
         const sh = shadowA * Math.min(1, Math.max(0, sampleBilinear(sd, WIN, WIN, u - DROP_SHADOW_DX, v - DROP_SHADOW_DY) / DROP_SHADOW_SOFTNESS + 0.5));
-        if (cav > 0) {
-          let om = original[i];
-          if (om === MATERIAL_EMPTY) om = this.nearbyMaterial(original, x, y);
-          if (om !== MATERIAL_EMPTY) {
-            const c = this.tiles[om]![scaledTileIndex(x0 * S + tx, y0 * S + ty, S)];
-            const k = cavityK[p] * (1 - sh * 0.55);
-            bgR = (c >> 16) * k;
-            bgG = ((c >> 8) & 0xff) * k * 0.95;
-            bgB = (c & 0xff) * k * 0.9;
-            bgA = cav;
-          }
+        if (cav > 0 && this.blend(original, x0, y0, px, py, x0 * S + tx, y0 * S + ty)) {
+          const k = sampleBilinear(cavityK, CHUNK_SIZE, CHUNK_SIZE, px, py) * (1 - sh * 0.55);
+          bgR = this.br * k;
+          bgG = this.bg * k * 0.95;
+          bgB = this.bb * k * 0.9;
+          bgA = cav;
         }
         if (sh > 0 && bgA < 1) {
           const a = sh * (1 - bgA);
@@ -282,28 +279,22 @@ export class TerrainView {
         // Terrain layer: textured fill inside a felt-pen outline.
         const d = sampleBilinear(sd, WIN, WIN, u, v);
         const fa = coverage(d);
-        let fr = 0;
-        let fg = 0;
-        let fb = 0;
+        let fr = OR;
+        let fg = OG;
+        let fb = OB;
         if (fa > 0) {
-          const t = this.thickness[tileIndex(x, y)];
+          const t = this.thickness[tileIndex(x0 + Math.floor(px), y0 + Math.floor(py))];
           const ink = 1 - coverage(d - t);
-          // Felt pen: slightly uneven ink.
-          const inkK = 1 + (hash2(x >> 1, y >> 1, 99) - 0.5) * 0.35;
-          fr = OR * inkK;
-          fg = OG * inkK;
-          fb = OB * inkK;
-          const mat = data[i];
-          if (ink < 1 && mat !== MATERIAL_EMPTY) {
-            const c = this.tiles[mat]![scaledTileIndex(x0 * S + tx, y0 * S + ty, S)];
-            const k = shadeK[p];
-            const w = shadeW[p];
-            const r = (c >> 16) * k;
-            const g = ((c >> 8) & 0xff) * k;
-            const b = (c & 0xff) * k;
-            fr = (r + (255 - r) * w) * (1 - ink) + fr * ink;
-            fg = (g + (255 - g) * w) * (1 - ink) + fg * ink;
-            fb = (b + (255 - b) * w) * (1 - ink) + fb * ink;
+          if (ink < 1 && this.blend(data, x0, y0, px, py, x0 * S + tx, y0 * S + ty)) {
+            // Lighting interpolated between pixel centers: no steps in the gradients.
+            const k = sampleBilinear(shadeK, CHUNK_SIZE, CHUNK_SIZE, px, py);
+            const w = sampleBilinear(shadeW, CHUNK_SIZE, CHUNK_SIZE, px, py);
+            const r = this.br * k;
+            const g = this.bg * k;
+            const b = this.bb * k;
+            fr = (r + (255 - r) * w) * (1 - ink) + OR * ink;
+            fg = (g + (255 - g) * w) * (1 - ink) + OG * ink;
+            fb = (b + (255 - b) * w) * (1 - ink) + OB * ink;
           }
         }
 
@@ -320,26 +311,62 @@ export class TerrainView {
     chunk.texture.source.update();
   }
 
-  /** A material of the original map next to (x, y), for antialiased cavity edges. */
-  private nearbyMaterial(original: Uint8Array, x: number, y: number): number {
+  /**
+   * Material color at a point (chunk-local pixel coordinates px, py; texel tx, ty), mixed
+   * bilinearly between the four nearest pixels so that boundaries between materials are soft
+   * instead of pixel steps. Empty pixels don't count. Result in br/bg/bb; false if all empty.
+   */
+  private blend(grid: Uint8Array, x0: number, y0: number, px: number, py: number, tx: number, ty: number): boolean {
     const { width, height } = this.bitmap;
-    for (const [dx, dy] of [
-      [1, 0],
-      [-1, 0],
-      [0, 1],
-      [0, -1],
-      [1, 1],
-      [-1, -1],
-      [1, -1],
-      [-1, 1],
-    ]) {
-      const nx = x + dx;
-      const ny = y + dy;
-      if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
-      const m = original[ny * width + nx];
-      if (m !== MATERIAL_EMPTY) return m;
+    const fx = px - 0.5;
+    const fy = py - 0.5;
+    const ix = Math.floor(fx);
+    const iy = Math.floor(fy);
+    const ax = fx - ix;
+    const ay = fy - iy;
+    const t = scaledTileIndex(tx, ty, S);
+    let r = 0;
+    let g = 0;
+    let b = 0;
+    let wsum = 0;
+    let first = -1;
+    let same = true;
+    for (let k = 0; k < 4; k++) {
+      const x = Math.max(0, Math.min(width - 1, x0 + ix + (k & 1)));
+      const y = Math.max(0, Math.min(height - 1, y0 + iy + (k >> 1)));
+      const m = grid[y * width + x];
+      if (first === -1) first = m;
+      else if (m !== first) same = false;
+      if (m === MATERIAL_EMPTY) continue;
+      const w = (k & 1 ? ax : 1 - ax) * (k >> 1 ? ay : 1 - ay);
+      if (w <= 0) continue;
+      const c = this.tiles[m]![t];
+      r += (c >> 16) * w;
+      g += ((c >> 8) & 0xff) * w;
+      b += (c & 0xff) * w;
+      wsum += w;
+    }
+    if (same && first !== MATERIAL_EMPTY) {
+      const c = this.tiles[first]![t];
+      this.br = c >> 16;
+      this.bg = (c >> 8) & 0xff;
+      this.bb = c & 0xff;
+      return true;
+    }
+    if (wsum === 0) {
+      // Only the zero-weight corner is solid (texel right on a pixel center line): use any solid neighbor.
+      const m = grid[Math.max(0, Math.min(height - 1, y0 + Math.floor(py))) * width + Math.max(0, Math.min(width - 1, x0 + Math.floor(px)))];
+      if (m === MATERIAL_EMPTY) return false;
+      const c = this.tiles[m]![t];
+      this.br = c >> 16;
+      this.bg = (c >> 8) & 0xff;
+      this.bb = c & 0xff;
+      return true;
     }
-    return MATERIAL_EMPTY;
+    this.br = r / wsum;
+    this.bg = g / wsum;
+    this.bb = b / wsum;
+    return true;
   }
 
   /** Fills the per-column distance tables for column x over rows [y0, y1). */

+ 21 - 0
src/renderer/terrain/distance.ts

@@ -106,3 +106,24 @@ export function sampleBilinear(grid: Float32Array, w: number, h: number, u: numb
   const e = grid[i + w + 1];
   return a + (b - a) * fx + (c - a) * fy + (a - b - c + e) * fx * fy;
 }
+
+let tmp = new Float32Array(0);
+
+/** In-place [1 2 1]/4 blur along both axes (edges clamped). */
+export function smooth121(grid: Float32Array, w: number, h: number): void {
+  if (tmp.length < w * h) tmp = new Float32Array(w * h);
+  for (let y = 0; y < h; y++) {
+    const row = y * w;
+    for (let x = 0; x < w; x++) {
+      const l = grid[row + Math.max(0, x - 1)];
+      const r = grid[row + Math.min(w - 1, x + 1)];
+      tmp[row + x] = (l + 2 * grid[row + x] + r) / 4;
+    }
+  }
+  for (let y = 0; y < h; y++) {
+    const up = Math.max(0, y - 1) * w;
+    const down = Math.min(h - 1, y + 1) * w;
+    const row = y * w;
+    for (let x = 0; x < w; x++) grid[row + x] = (tmp[up + x] + 2 * tmp[row + x] + tmp[down + x]) / 4;
+  }
+}