scene: terrain falls back to its sky alone on a 2D canvas where WebGL2 is missing
createTerrainScene returned null without WebGL2, leaving plain dark ground. The door now mounts terrain/TerrainSky2D.ts on the same canvas: skyColour (the CPU twin of SKY_FRAG) painted once, the star layer on top when dark, same ?sky= ?sun= ?az= choice. chooseSky moves from Terrain.ts to terrainSky.ts so both scenes share one owner. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
40b1e775fe
commit
e007514af3
6 changed files with 337 additions and 30 deletions
|
|
@ -51,6 +51,11 @@ Page URL: `?sky=midnight|noonday` picks a bake; `?sun=<elevation deg>&az=<deg fr
|
|||
(`skyAtSun`: baked transmittance for the sun's colour, ambient and horizon blended from the midnight bake to the noonday one as
|
||||
the sun climbs from -8 to 25 degrees). `?sun=` wins over `?sky=`.
|
||||
|
||||
Without WebGL2 (`createTerrainScene` returns null) the door mounts `terrain/TerrainSky2D.ts` on the same canvas: the sky alone, painted once on a
|
||||
96 px wide 2D canvas from `skyColour` (the CPU twin of `SKY_FRAG`, same `?sky=` `?sun=` `?az=` choice via `chooseSky`), the star layer on
|
||||
top when the sky is dark, nothing moving, no sun disc. Below the horizon the image continues in the horizon's colour. A canvas that already
|
||||
holds a GL context gives no 2D one, so a GL failure after context creation (compile, framebuffer) still ends in the CSS ground.
|
||||
|
||||
- Sky: `1 - exp(-SKY_EXPOSURE * radiance)` plus a night glow (the midnight horizon colour, `nightness` 1 below -4 degrees, 0 above 4), a
|
||||
sun disc from the sun's colour, and a +-0.5/255 dither on the 8-bit output. The table is single scattering only (no ground bounce,
|
||||
no multiple scattering), so the noon horizon is pale yellow-green and a sun just under the horizon colours the whole sky orange,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ export type StartOptions = Omit<SceneOptions, 'tier' | 'patchSource' | 'onCompil
|
|||
/**
|
||||
* The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no
|
||||
* canvas context is requested; otherwise the scene code loads on demand, so it stays out of the
|
||||
* main chunk. `config.style` picks the scene: the ridges unless it says 'raymarch' or 'terrain'. Null means
|
||||
* main chunk. `config.style` picks the scene: the ridges unless it says 'raymarch' or 'terrain' (which falls back to its
|
||||
* sky alone, `terrain/TerrainSky2D.ts`, where WebGL2 is missing). Null means
|
||||
* "keep the CSS ground", including when that chunk fails to load (offline, stale deploy): the
|
||||
* failure goes to onCompileFail and nothing throws.
|
||||
*/
|
||||
|
|
@ -26,9 +27,14 @@ export async function startBackgroundScene(
|
|||
let createScene: (o: SceneOptions) => Scene | null;
|
||||
try {
|
||||
if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene;
|
||||
else if (config?.style === 'terrain')
|
||||
createScene = (await import('./terrain/Terrain')).createTerrainScene;
|
||||
else createScene = (await import('./ridge/Ridge')).createRidgeScene;
|
||||
else if (config?.style === 'terrain') {
|
||||
const { createTerrainScene } = await import('./terrain/Terrain');
|
||||
const scene = createTerrainScene({ ...options, tier });
|
||||
// No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground.
|
||||
if (scene) return scene;
|
||||
const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D');
|
||||
return createTerrainSkyScene({ ...options, tier });
|
||||
} else createScene = (await import('./ridge/Ridge')).createRidgeScene;
|
||||
} catch (e) {
|
||||
options.onCompileFail?.(e instanceof Error ? e : new Error(String(e)));
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,20 +1,9 @@
|
|||
import type { Scene, SceneOptions } from '../Scene';
|
||||
import { FrameSampler, FrameStats } from '../ridge/probe';
|
||||
import { skyColours } from '../ridge/skyColour';
|
||||
import { albedoPalette } from './terrainAlbedo';
|
||||
import { sunIndex } from '../../../../sky/scatter.js';
|
||||
import { SKY_VIEW, skyViewWords } from './terrainScatter';
|
||||
import {
|
||||
glowColour,
|
||||
HAZE_K,
|
||||
isSkyName,
|
||||
skyAtSun,
|
||||
SkyData,
|
||||
skyData,
|
||||
SkyName,
|
||||
skyNameFor,
|
||||
sunDirection,
|
||||
} from './terrainSky';
|
||||
import { glowColour, HAZE_K, chooseSky, SkyName, sunDirection } from './terrainSky';
|
||||
import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld';
|
||||
import {
|
||||
FULLSCREEN_VERT,
|
||||
|
|
@ -77,20 +66,6 @@ function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: strin
|
|||
return p;
|
||||
}
|
||||
|
||||
/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */
|
||||
function chooseSky(opts: Pick<TerrainOptions, 'sky' | 'sun'>): SkyData {
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN);
|
||||
const elevation = opts.sun?.elevation ?? number('sun');
|
||||
if (Number.isFinite(elevation)) {
|
||||
const azimuth = opts.sun?.azimuth ?? number('az');
|
||||
return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined);
|
||||
}
|
||||
if (opts.sky) return skyData(opts.sky);
|
||||
const name = q.get('sky');
|
||||
return skyData(isSkyName(name) ? name : skyNameFor(skyColours()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the
|
||||
* CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an
|
||||
|
|
|
|||
220
src/app/features/scene/terrain/TerrainSky2D.test.ts
Normal file
220
src/app/features/scene/terrain/TerrainSky2D.test.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createTerrainSkyScene, SKY_WIDTH } from './TerrainSky2D';
|
||||
import { startBackgroundScene } from '../index';
|
||||
|
||||
let search: string;
|
||||
let arcs: number;
|
||||
let overlay: { remove: ReturnType<typeof vi.fn> };
|
||||
let windowListeners: Record<string, () => void>;
|
||||
beforeEach(() => {
|
||||
search = '';
|
||||
arcs = 0;
|
||||
windowListeners = {};
|
||||
vi.stubGlobal('document', {
|
||||
createElement: () => {
|
||||
overlay = {
|
||||
remove: vi.fn(),
|
||||
style: {},
|
||||
setAttribute() {},
|
||||
getContext: () => ({
|
||||
clearRect() {},
|
||||
beginPath() {},
|
||||
fill() {},
|
||||
arc: () => {
|
||||
arcs += 1;
|
||||
},
|
||||
}),
|
||||
} as any;
|
||||
return overlay;
|
||||
},
|
||||
});
|
||||
vi.stubGlobal('window', {
|
||||
devicePixelRatio: 1,
|
||||
innerWidth: 400,
|
||||
innerHeight: 800,
|
||||
addEventListener: (n: string, f: () => void) => {
|
||||
windowListeners[n] = f;
|
||||
},
|
||||
removeEventListener: (n: string) => {
|
||||
delete windowListeners[n];
|
||||
},
|
||||
get location() {
|
||||
return { search };
|
||||
},
|
||||
setTimeout,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** A canvas with a recording 2D context; `gl` is what 'webgl2' answers. */
|
||||
function canvasWith(opts: { twoD?: boolean; gl?: unknown } = {}) {
|
||||
const painted: { data: Uint8ClampedArray; width: number; height: number }[] = [];
|
||||
const ctx = {
|
||||
createImageData: (width: number, height: number) => ({
|
||||
width,
|
||||
height,
|
||||
data: new Uint8ClampedArray(width * height * 4),
|
||||
}),
|
||||
putImageData: (image: any) => painted.push(image),
|
||||
};
|
||||
const parentAppend = vi.fn();
|
||||
const canvas: any = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
clientWidth: 400,
|
||||
clientHeight: 800,
|
||||
parentElement: { appendChild: parentAppend },
|
||||
getContext: vi.fn((kind: string) => {
|
||||
if (kind !== '2d') return opts.gl ?? null;
|
||||
return opts.twoD === false ? null : ctx;
|
||||
}),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
return { canvas, painted, parentAppend };
|
||||
}
|
||||
|
||||
const pixel = (img: { data: Uint8ClampedArray; width: number }, x: number, y: number) =>
|
||||
Array.from(img.data.slice((y * img.width + x) * 4, (y * img.width + x) * 4 + 3));
|
||||
const mean = (img: { data: Uint8ClampedArray }) => {
|
||||
let s = 0;
|
||||
for (let i = 0; i < img.data.length; i += 4) s += img.data[i] + img.data[i + 1] + img.data[i + 2];
|
||||
return s / (img.data.length / 4) / 3;
|
||||
};
|
||||
|
||||
describe('createTerrainSkyScene', () => {
|
||||
it("returns null at tier 'off' and touches nothing", () => {
|
||||
const { canvas } = canvasWith();
|
||||
expect(createTerrainSkyScene({ canvas, tier: 'off' })).toBeNull();
|
||||
expect(canvas.getContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when the canvas gives no 2D context (the CSS ground stays)', () => {
|
||||
const { canvas } = canvasWith({ twoD: false });
|
||||
expect(createTerrainSkyScene({ canvas, tier: 'low' })).toBeNull();
|
||||
});
|
||||
|
||||
it('paints one opaque, graded sky: the horizon end of the image is not the zenith end', () => {
|
||||
const { canvas, painted } = canvasWith();
|
||||
const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!;
|
||||
expect(painted).toHaveLength(1);
|
||||
const img = painted[0];
|
||||
expect(img.width).toBe(SKY_WIDTH);
|
||||
expect(canvas.width).toBe(SKY_WIDTH);
|
||||
expect(canvas.height).toBe(img.height);
|
||||
for (let i = 3; i < img.data.length; i += 4) expect(img.data[i]).toBe(255);
|
||||
const x = Math.floor(SKY_WIDTH / 2);
|
||||
expect(pixel(img, x, 0)).not.toEqual(pixel(img, x, img.height - 1));
|
||||
expect(mean(img)).toBeGreaterThan(20);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
it('follows the baked sky: noon is brighter than midnight, and ?sun= / ?az= move it', () => {
|
||||
const noon = canvasWith();
|
||||
createTerrainSkyScene({ canvas: noon.canvas, tier: 'low', sky: 'noonday' })!.dispose();
|
||||
const night = canvasWith();
|
||||
createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!.dispose();
|
||||
expect(mean(noon.painted[0])).toBeGreaterThan(mean(night.painted[0]) * 1.5);
|
||||
|
||||
const low = canvasWith();
|
||||
createTerrainSkyScene({ canvas: low.canvas, tier: 'low', sun: { elevation: 30 } })!.dispose();
|
||||
const down = canvasWith();
|
||||
createTerrainSkyScene({ canvas: down.canvas, tier: 'low', sun: { elevation: -20 } })!.dispose();
|
||||
expect(mean(low.painted[0])).toBeGreaterThan(mean(down.painted[0]));
|
||||
|
||||
search = '?sun=30&az=0';
|
||||
const q0 = canvasWith();
|
||||
createTerrainSkyScene({ canvas: q0.canvas, tier: 'low' })!.dispose();
|
||||
search = '?sun=30&az=180';
|
||||
const q1 = canvasWith();
|
||||
createTerrainSkyScene({ canvas: q1.canvas, tier: 'low' })!.dispose();
|
||||
expect(Array.from(q0.painted[0].data)).not.toEqual(Array.from(q1.painted[0].data));
|
||||
});
|
||||
|
||||
it('stars only on a dark sky', () => {
|
||||
const night = canvasWith();
|
||||
const a = createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!;
|
||||
expect(night.parentAppend).toHaveBeenCalledTimes(1);
|
||||
expect(arcs).toBeGreaterThan(0);
|
||||
a.dispose();
|
||||
expect(overlay.remove).toHaveBeenCalled();
|
||||
arcs = 0;
|
||||
const day = canvasWith();
|
||||
createTerrainSkyScene({ canvas: day.canvas, tier: 'low', sky: 'noonday' })!.dispose();
|
||||
expect(day.parentAppend).not.toHaveBeenCalled();
|
||||
expect(arcs).toBe(0);
|
||||
});
|
||||
|
||||
it('repaints on resize, and dispose stops listening', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('window', {
|
||||
...(window as any),
|
||||
setTimeout,
|
||||
addEventListener: (window as any).addEventListener,
|
||||
});
|
||||
const { canvas, painted } = canvasWith();
|
||||
const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!;
|
||||
expect(windowListeners.resize).toBeDefined();
|
||||
windowListeners.resize();
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(painted).toHaveLength(2);
|
||||
s.dispose();
|
||||
expect(windowListeners.resize).toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startBackgroundScene, terrain style', () => {
|
||||
it('without WebGL2 mounts the sky on the same canvas instead of returning null', async () => {
|
||||
const { canvas, painted } = canvasWith({ gl: null });
|
||||
const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas });
|
||||
expect(scene).not.toBeNull();
|
||||
expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2', '2d']);
|
||||
expect(painted).toHaveLength(1);
|
||||
scene!.dispose();
|
||||
});
|
||||
|
||||
it('with WebGL2 the terrain runs and the 2D sky is never requested', async () => {
|
||||
const gl: any = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, name: string) {
|
||||
if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true;
|
||||
if (name === 'checkFramebufferStatus') return () => 1;
|
||||
if (name === 'getParameter') return () => 16;
|
||||
if (/^[A-Z_0-9]+$/.test(name)) return 1;
|
||||
return () => ({});
|
||||
},
|
||||
}
|
||||
);
|
||||
Object.assign(document, {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
visibilityState: 'visible',
|
||||
});
|
||||
vi.stubGlobal('requestAnimationFrame', () => 1);
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {});
|
||||
vi.stubGlobal('performance', { now: () => 0 });
|
||||
(window as any).matchMedia = () => ({
|
||||
matches: false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
});
|
||||
const { canvas, painted } = canvasWith({ gl });
|
||||
const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas });
|
||||
expect(scene).not.toBeNull();
|
||||
expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2']);
|
||||
expect(painted).toHaveLength(0);
|
||||
scene!.dispose();
|
||||
});
|
||||
|
||||
it('a canvas that took a GL context but gives no scene still ends as null: no 2D context to paint on', async () => {
|
||||
const { canvas } = canvasWith({ gl: null, twoD: false });
|
||||
expect(
|
||||
await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas })
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
83
src/app/features/scene/terrain/TerrainSky2D.ts
Normal file
83
src/app/features/scene/terrain/TerrainSky2D.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { Scene } from '../Scene';
|
||||
import type { TerrainOptions } from './Terrain';
|
||||
import { chooseSky, skyColour, Vec3 } from './terrainSky';
|
||||
import { createTerrainStars } from './terrainStars';
|
||||
import { CAMERA, view } from './terrainWorld';
|
||||
|
||||
/** The sky is smooth, so it is painted this many px wide and stretched by the canvas's CSS size. */
|
||||
export const SKY_WIDTH = 96;
|
||||
|
||||
/** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */
|
||||
export type TerrainSkyOptions = Pick<TerrainOptions, 'canvas' | 'tier' | 'sky' | 'sun'>;
|
||||
|
||||
/**
|
||||
* Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the
|
||||
* same baked tables and the same `?sun=` `?az=` `?sky=` choice) for the camera at its start, still: nothing moves, so
|
||||
* a device that cannot run WebGL2 pays one paint and one resize repaint. Below the horizon there is no terrain, so
|
||||
* the image continues with the horizon's colour. Null for "keep the CSS ground": tier off, or the canvas already
|
||||
* holds a WebGL context and gives no 2D one.
|
||||
*/
|
||||
export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null {
|
||||
if ((opts.tier ?? 'full') === 'off') return null;
|
||||
const { canvas } = opts;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
const sky = chooseSky(opts);
|
||||
const stars = createTerrainStars(canvas, sky);
|
||||
const tanHalf = Math.tan(CAMERA.fov / 2);
|
||||
let disposed = false;
|
||||
let resizeTimer = 0;
|
||||
|
||||
const paint = () => {
|
||||
const w = SKY_WIDTH;
|
||||
const cw = canvas.clientWidth || window.innerWidth;
|
||||
const ch = canvas.clientHeight || window.innerHeight;
|
||||
const h = Math.max(1, Math.round((w * ch) / cw));
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const v = view(cw / ch, 0);
|
||||
const image = ctx.createImageData(w, h);
|
||||
for (let y = 0; y < h; y += 1) {
|
||||
const ny = 1 - ((y + 0.5) / h) * 2;
|
||||
for (let x = 0; x < w; x += 1) {
|
||||
const nx = ((x + 0.5) / w) * 2 - 1;
|
||||
const d = [0, 1, 2].map(
|
||||
(i) => v.forward[i] + nx * tanHalf * (cw / ch) * v.right[i] + ny * tanHalf * v.up[i]
|
||||
);
|
||||
const len = Math.hypot(d[0], d[1], d[2]);
|
||||
const c: Vec3 = skyColour(sky, [d[0] / len, d[1] / len, d[2] / len]);
|
||||
const at = (y * w + x) * 4;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
image.data[at + i] = Math.round(255 * Math.min(1, Math.max(0, c[i])) ** (1 / 2.2));
|
||||
}
|
||||
image.data[at + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
stars?.draw(0);
|
||||
};
|
||||
|
||||
const onResize = () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => {
|
||||
if (disposed) return;
|
||||
stars?.resize();
|
||||
paint();
|
||||
}, 250);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
paint();
|
||||
|
||||
return {
|
||||
login() {
|
||||
// Nothing moves, so there is no pull-back to start.
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
clearTimeout(resizeTimer);
|
||||
window.removeEventListener('resize', onResize);
|
||||
stars?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms.
|
||||
import { baked } from '../../../../sky/baked';
|
||||
import { isNight } from '../ridge/stars';
|
||||
import { skyColours } from '../ridge/skyColour';
|
||||
import type { SkyColours } from '../ridge/skyColour';
|
||||
import { HORIZON_FLOOR, scatterRadiance, transmittanceAt } from './terrainScatter';
|
||||
|
||||
|
|
@ -149,3 +150,20 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number,
|
|||
return lit * t + haze[i] * (1 - t);
|
||||
}) as unknown as Vec3;
|
||||
}
|
||||
|
||||
/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */
|
||||
export function chooseSky(opts: {
|
||||
sky?: SkyName;
|
||||
sun?: { elevation: number; azimuth?: number };
|
||||
}): SkyData {
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN);
|
||||
const elevation = opts.sun?.elevation ?? number('sun');
|
||||
if (Number.isFinite(elevation)) {
|
||||
const azimuth = opts.sun?.azimuth ?? number('az');
|
||||
return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined);
|
||||
}
|
||||
if (opts.sky) return skyData(opts.sky);
|
||||
const name = q.get('sky');
|
||||
return skyData(isSkyName(name) ? name : skyNameFor(skyColours()));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue