From 15092f1b383cbbe7d639e13f28a792cc2e678a82 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 4 Oct 2026 10:57:05 -0700 Subject: [PATCH 1/6] feat(world): stack uncompressed terrain layers into one array per set (PRD-493 phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixteen same-size uncompressed layers bound one sampler each, which is what makes WebGPU's 16 sampled textures a stage the real cap on Android and iOS — the hosts with no KTX2 transcoder, where nothing stacks today. Each set now becomes one DataArrayTexture whose layers are filled by a renderer.copyTextureToTexture out of their own textures: the pixels never come back to the CPU, and the array is created with source.dataReady = false so three never uploads placeholder bytes over the copies. Mixed sizes still fall back, and TN_TERRAIN_SPLAT layers= samplers= stacked= reports what the material actually costs, one line per material. Also corrects the stale @constraint: the recipe is export_terrain_layers in export_world.py, and the cap is now a sampler count the marker names. capabilities.json and the generated reference carry both. --- ...93-terrain-layers-past-sixteen-textures.md | 8 +- .../__tests__/world-terrain-splat.spec.ts | 133 +++++++++++++++++- packages/core/capabilities.json | 6 +- packages/core/src/world-terrain-splat.ts | 130 +++++++++++++++-- .../references/capability-reference.md | 4 +- packages/create-threenative/capabilities.json | 6 +- 6 files changed, 258 insertions(+), 29 deletions(-) diff --git a/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md b/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md index 52143ae24d..a4dc093b31 100644 --- a/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md +++ b/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md @@ -40,10 +40,12 @@ The blend curve keeps its current form (mask, `lo`/`hi`, breakup push, macro noi ## Execution Phases #### Phase 1: Every same-size set is one sampler -**Status:** NOT STARTED +**Status:** LANDED **Files:** `packages/core/src/world-terrain-splat.ts`; `packages/core/__tests__/world-terrain-splat.spec.ts` -- [ ] Sixteen uncompressed same-size layers with normals build a material that references one array texture per set (3 sampled textures with the splat array). Mixed sizes still fall back and the marker names the count. proof: red-green `pnpm exec vitest run packages/core/__tests__/world-terrain-splat.spec.ts`. -- [ ] The `@constraint` and recipe name are corrected, and the regenerated manifest carries them. proof: `pnpm build` then `pnpm exec vitest run scripts/__tests__/capability-manifest.spec.ts scripts/__tests__/generate-capability-reference.spec.ts`, both exit 0. +- [x] Sixteen uncompressed same-size layers with normals build a material that references one array texture per set (3 sampled textures with the splat array). Mixed sizes still fall back and the marker names the count. proof: red-green `pnpm exec vitest run packages/core/__tests__/world-terrain-splat.spec.ts` — 3 red (uncompressed stack `undefined`, `renderer` unknown), then 9/9 green exit 0; the 16-layer case walks the node graph and counts 3 distinct sampled textures, and `TN_TERRAIN_SPLAT layers=4 samplers=6 stacked=1` names the fallback's cost. +- [x] The `@constraint` and recipe name are corrected, and the regenerated manifest carries them. proof: `pnpm build` exit 0, then `pnpm exec vitest run scripts/__tests__/capability-manifest.spec.ts scripts/__tests__/generate-capability-reference.spec.ts` — 28/28 green, both exit 0. `pnpm typecheck` and `pnpm lint` exit 0. + +Uncompressed layers stack through `renderer.copyTextureToTexture` per layer (`ILoadTerrainSplatOptions.renderer`, so a game passes `ctx.renderer`); the array itself is created with `source.dataReady = false`, so no CPU pixel read and no zero upload stand in for a copy. Without a renderer every layer keeps its own sampler and the marker says what that costs. #### Phase 2: ORM per layer **Status:** NOT STARTED diff --git a/packages/core/__tests__/world-terrain-splat.spec.ts b/packages/core/__tests__/world-terrain-splat.spec.ts index 8e332cede4..159fb8a109 100644 --- a/packages/core/__tests__/world-terrain-splat.spec.ts +++ b/packages/core/__tests__/world-terrain-splat.spec.ts @@ -1,4 +1,4 @@ -import { CompressedArrayTexture, CompressedTexture, Texture } from "three"; +import { CompressedArrayTexture, CompressedTexture, DataArrayTexture, Texture } from "three"; import { MeshStandardNodeMaterial } from "three/webgpu"; import { afterEach, describe, expect, it, vi } from "vitest"; import { loadTerrainSplat, stackLayers } from "../src/world-terrain-splat.js"; @@ -57,13 +57,55 @@ function served(files: Record): { assets: never; requested: str resolve: async (path: string) => [path], texture: async (path: string) => { requested.push(path); - return new Texture(); + return bitmap(4, 4); }, }; return { assets: assets as never, requested }; } -afterEach(() => vi.unstubAllGlobals()); +/** An uncompressed layer of a given size, as the asset loader hands one back (an ImageBitmap). */ +function bitmap(width: number, height: number): Texture { + const texture = new Texture(); + texture.image = { width, height } as never; + return texture; +} + +/** A renderer that records the GPU copies it is asked for, one per array layer. */ +function copyingRenderer(): { raw: unknown; layers: number[] } { + const layers: number[] = []; + return { + layers, + raw: { + copyTextureToTexture: ( + _source: unknown, + _destination: unknown, + _r: unknown, + at: { z: number }, + ) => layers.push(at.z), + initTexture: () => undefined, + }, + }; +} + +/** Every distinct texture the material's node graphs sample: one binding each. */ +function sampledTextures(material: MeshStandardNodeMaterial): Set { + const found = new Set(); + const walk = ( + node: { value?: unknown; getChildren?: () => unknown[] } | null | undefined, + ): void => { + if (node === null || node === undefined) return; + if (node.value instanceof Texture) found.add(node.value); + for (const child of node.getChildren?.() ?? []) walk(child as typeof node); + }; + walk(material.colorNode as never); + walk(material.normalNode as never); + return found; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); describe("loadTerrainSplat", () => { it("builds the package's splat surface from its own table, planes and texture sets", async () => { @@ -104,6 +146,72 @@ describe("loadTerrainSplat", () => { }); await expect(loadTerrainSplat({ assets, url: "world/world.json" })).rejects.toThrow(/plane 1/u); }); + + it("stacks sixteen same-size uncompressed layers into one array texture per set", async () => { + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + const { assets } = served({ + "world/world.json": world({ splat: "terrain/splat.rgba8", table: "terrain/layers.json" }), + "world/terrain/layers.json": { + ...table, + layers: Array.from({ length: 15 }, (_unused, index) => ({ + channel: "r", + hi: 1, + id: `layer-${String(index)}`, + lo: 0, + mask: "a", + normal: true, + tile: 3, + tint: [1, 1, 1], + })), + splat: { masks: { a: [0, "a"] }, planes: 1, size: 4 }, + }, + "world/terrain/splat.rgba8": new Uint8Array(4 * 4 * 4), + }); + const surface = (await loadTerrainSplat({ + assets, + renderer: copyingRenderer() as never, + url: "world/world.json", + })) as MeshStandardNodeMaterial; + // The splat planes plus one array per set: three sampled textures for sixteen layers. + expect(sampledTextures(surface).size).toBe(3); + expect(info).toHaveBeenCalledWith("TN_TERRAIN_SPLAT layers=16 samplers=3 stacked=2"); + }); + + it("names the sampler count a set that cannot stack costs", async () => { + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const sizes = new Map(); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url === "world/world.json") + return new Response( + JSON.stringify(world({ splat: "terrain/splat.rgba8", table: "terrain/layers.json" })), + ); + if (url === "world/terrain/splat.rgba8") + return new Response(new Blob([new Uint8Array(2 * 4 * 4 * 4)])); + return new Response(JSON.stringify(table)); + }), + ); + // One layer at a size the others cannot match, so the albedo set keeps its own samplers. + sizes.set("world/terrain/tex/rock_diff.jpg", [8, 8]); + const assets = { + resolve: async (path: string) => [path], + texture: async (path: string) => { + const size = sizes.get(path) ?? [4, 4]; + return bitmap(size[0], size[1]); + }, + }; + const surface = (await loadTerrainSplat({ + assets: assets as never, + renderer: copyingRenderer() as never, + url: "world/world.json", + })) as MeshStandardNodeMaterial; + // splat + 4 albedos (one layer's size the rest do not share) + 1 stacked normal set. + expect(sampledTextures(surface).size).toBe(6); + expect(info).toHaveBeenCalledWith("TN_TERRAIN_SPLAT layers=4 samplers=6 stacked=1"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("bind 4 samplers")); + }); }); describe("stackLayers", () => { @@ -130,8 +238,23 @@ describe("stackLayers", () => { expect((stacked.mipmaps[1]?.data as Uint8Array).byteLength).toBe(24); }); - it("refuses to stack a mixed codec or an uncompressed layer", () => { + it("refuses to stack a mixed codec", () => { expect(stackLayers([layer(1), layer(2, 1024)])).toBeUndefined(); - expect(stackLayers([new Texture(), new Texture()])).toBeUndefined(); + }); + + it("stacks same-size uncompressed layers through one GPU copy per layer", () => { + const renderer = copyingRenderer(); + const stacked = stackLayers( + [bitmap(4, 4), bitmap(4, 4), bitmap(4, 4)], + renderer as never, + ) as DataArrayTexture; + expect(stacked).toBeInstanceOf(DataArrayTexture); + expect((stacked.image as { depth: number }).depth).toBe(3); + expect(renderer.layers).toEqual([0, 1, 2]); + }); + + it("refuses uncompressed layers of mixed sizes, and any set with no renderer to copy with", () => { + expect(stackLayers([bitmap(4, 4), bitmap(8, 8)], copyingRenderer() as never)).toBeUndefined(); + expect(stackLayers([bitmap(4, 4), bitmap(4, 4)])).toBeUndefined(); }); }); diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index a6686b28b8..bb63d81701 100644 --- a/packages/core/capabilities.json +++ b/packages/core/capabilities.json @@ -3730,10 +3730,10 @@ { "aliases": [], "constraints": [ - "the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by the `export_terrain_layers.py` recipe", - "WebGPU allows 16 sampled textures per stage: planes + diffuse maps + normal maps must fit" + "the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by `export_terrain_layers` in the `export_world.py` recipe", + "WebGPU allows 16 sampled textures per stage: same-size layers stack into one array texture per set, and `TN_TERRAIN_SPLAT samplers=` reports what is left" ], - "example": "const surface = await loadTerrainSplat({ assets: ctx.assets, url: \"world/world.json\" });\nconst world = await WorldCells.load({ assets: ctx.assets, url: \"world/world.json\", surface, follow, ring: 2 });", + "example": "const surface = await loadTerrainSplat({ assets: ctx.assets, renderer: ctx.renderer, url: \"world/world.json\" });\nconst world = await WorldCells.load({ assets: ctx.assets, url: \"world/world.json\", surface, follow, ring: 2 });", "importPath": "@threenative/core/world", "kind": "function", "overrides": [ diff --git a/packages/core/src/world-terrain-splat.ts b/packages/core/src/world-terrain-splat.ts index 7c6cbca3bc..95201c672f 100644 --- a/packages/core/src/world-terrain-splat.ts +++ b/packages/core/src/world-terrain-splat.ts @@ -10,6 +10,7 @@ import { RepeatWrapping, type Texture, UnsignedByteType, + Vector3, } from "three"; import { cameraViewMatrix, @@ -29,6 +30,7 @@ import { import type { Node } from "three/webgpu"; import { MeshStandardNodeMaterial } from "three/webgpu"; import type { IAssetLoader } from "./assets.js"; +import type { IRendererLike } from "./renderer.js"; import type { IWorldExtent } from "./world-package.js"; type Channel = "r" | "g" | "b" | "a"; @@ -80,6 +82,11 @@ export interface ITerrainSplatTable { export interface ILoadTerrainSplatOptions { readonly assets: IAssetLoader; + /** + * The live renderer. Uncompressed layers stack into one array texture per set through GPU copies, + * which need one; without it every layer keeps its own sampler and the marker says so. + */ + readonly renderer?: IRendererLike; /** The world package's `world.json`, as `WorldCells.load` takes it. */ readonly url: string; } @@ -136,17 +143,104 @@ function splatArray(bytes: Uint8Array, size: number, planes: number): Texture { return map; } +/** The two texture-copy calls `stackUncompressed` needs, as the live renderer exposes them. */ +interface IRawTextureCopier { + readonly copyTextureToTexture?: ( + source: Texture, + destination: Texture, + sourceRegion?: unknown, + destinationPosition?: unknown, + ) => void; + readonly initTexture?: (texture: Texture) => void; +} + +/** A loaded layer's texel size on the GPU, whatever its pixels are held in. */ +function gpuSize(map: Texture): { readonly width: number; readonly height: number } | undefined { + const image = map.image as { width?: number; height?: number } | undefined; + const { width, height } = image ?? {}; + return typeof width === "number" && typeof height === "number" && width > 0 && height > 0 + ? { width, height } + : undefined; +} + +/** + * Same-size uncompressed layers as one `DataArrayTexture`: one sampler, whatever the layer count. + * Every layer arrives as a GPU copy out of its own texture, so the pixels never come back to the + * CPU — and the array itself is created with no CPU data to upload, its layers written instead. + * + * `undefined` when they cannot stack (a size the others do not share) or when the host has no + * initialised renderer to copy with. + */ +function stackUncompressed( + maps: readonly Texture[], + renderer: IRendererLike | undefined, +): Texture | undefined { + const raw = renderer?.raw as IRawTextureCopier | undefined; + if (raw === undefined || raw.initTexture === undefined || raw.copyTextureToTexture === undefined) + return undefined; + const size = gpuSize(maps[0] as Texture); + if (size === undefined) return undefined; + const sameSize = maps.every((map) => { + const other = gpuSize(map); + return other !== undefined && other.width === size.width && other.height === size.height; + }); + if (!sameSize) return undefined; + + const first = maps[0] as Texture; + const array = new DataArrayTexture( + new Uint8Array(size.width * size.height * 4 * maps.length), + size.width, + size.height, + maps.length, + ); + array.format = first.format; + array.type = first.type; + array.colorSpace = first.colorSpace; + array.minFilter = first.minFilter; + array.magFilter = first.magFilter; + array.wrapS = RepeatWrapping; + array.wrapT = RepeatWrapping; + array.anisotropy = 8; + array.flipY = false; + try { + // One creation of the GPU texture, with every mip level it will ever have, and nothing to + // upload: `dataReady` false is how three is told a texture's texels are already on the GPU. + array.needsUpdate = true; + array.source.dataReady = false; + raw.initTexture(array); + maps.forEach((map, layer) => { + raw.initTexture?.(map); + raw.copyTextureToTexture?.(map, array, null, new Vector3(0, 0, layer)); + }); + } catch (error) { + // An array nothing finished writing would draw whatever the copy left behind: fall back. + array.dispose(); + console.warn( + `TN_TERRAIN_SPLAT: GPU stacking failed (${String(error)}); these layers bind their own samplers.`, + ); + return undefined; + } + array.image.data = new Uint8Array(0); + for (const map of maps) map.dispose(); + return array; +} + /** - * Same-format, same-size compressed layers as one `CompressedArrayTexture`, or `undefined` when - * they cannot stack (a mixed codec, a size the cap did not equalise, an uncompressed fallback). + * Same-format, same-size compressed layers as one `CompressedArrayTexture`, and same-size + * uncompressed ones as one `DataArrayTexture`, or `undefined` when they cannot stack (a mixed + * codec, a size the cap did not equalise, no renderer to copy uncompressed layers with). * * WebGPU guarantees 16 samplers a stage and three binds one per texture; a splat terrain's albedos - * and normals as separate textures plus an open-world shadow's maps pass 16 and the pipeline is + * and normals as separate textures plus an open-world shadow's maps passes 16 and the pipeline is * invalid. Stacked, a surface costs one sampler per array however many layers it blends. */ -export function stackLayers(maps: readonly Texture[]): Texture | undefined { +export function stackLayers( + maps: readonly Texture[], + renderer?: IRendererLike, +): Texture | undefined { const first = maps[0] as CompressedTexture | undefined; - if (first === undefined || first.isCompressedTexture !== true) return undefined; + if (first === undefined) return undefined; + if (first.isCompressedTexture !== true) return stackUncompressed(maps, renderer); const width = (first.image as { width: number }).width; const height = (first.image as { height: number }).height; const stackable = maps.every((map) => { @@ -205,11 +299,11 @@ export function stackLayers(maps: readonly Texture[]): Texture | undefined { * * @situation terrain textured by splat masks exported from Blender with the world package * @situation the game's terrain should match the DCC's terrain material without a second copy - * @constraint the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by the `export_terrain_layers.py` recipe - * @constraint WebGPU allows 16 sampled textures per stage: planes + diffuse maps + normal maps must fit + * @constraint the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by `export_terrain_layers` in the `export_world.py` recipe + * @constraint WebGPU allows 16 sampled textures per stage: same-size layers stack into one array texture per set, and `TN_TERRAIN_SPLAT samplers=` reports what is left * @override every value comes from the package's table; the returned material is the game's to adjust * @example - * const surface = await loadTerrainSplat({ assets: ctx.assets, url: "world/world.json" }); + * const surface = await loadTerrainSplat({ assets: ctx.assets, renderer: ctx.renderer, url: "world/world.json" }); * const world = await WorldCells.load({ assets: ctx.assets, url: "world/world.json", surface, follow, ring: 2 }); */ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promise { @@ -218,7 +312,7 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi const layerPaths = world.layers; if (layerPaths.table === undefined || layerPaths.splat === undefined) throw new Error( - "loadTerrainSplat: world.json has no terrain.layers.table/splat; export them with export_terrain_layers.py.", + "loadTerrainSplat: world.json has no terrain.layers.table/splat; export them with the export_world.py recipe.", ); const dir = packageDir(url); const table = (await ( @@ -257,16 +351,22 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi const normalMaps = await Promise.all(withNormals.map((layer) => load(layer, "nrm"))); // One sampler per set when the layers stack; separate textures (and a warning) when they do not. + let samplers = 1; + let stackedSets = 0; const sampler = ( maps: readonly Texture[], what: string, ): ((layer: number, uv: Node<"vec2">) => Node<"vec3">) => { - const stacked = stackLayers(maps); - if (stacked !== undefined) return (layer, uv) => texture(stacked, uv).depth(int(layer)).rgb; + const stacked = stackLayers(maps, options.renderer); + if (stacked !== undefined) { + samplers += 1; + stackedSets += 1; + return (layer, uv) => texture(stacked, uv).depth(int(layer)).rgb; + } + samplers += maps.length; if (maps.length > 1) console.warn( - `TN_TERRAIN_SPLAT: ${what} layers could not stack into one array (mixed format or size); ` + - `they bind ${String(maps.length)} samplers, and WebGPU guarantees 16 a stage.`, + `TN_TERRAIN_SPLAT: ${what} layers could not stack into one array (mixed format or size, or no renderer to copy them with); they bind ${String(maps.length)} samplers, and WebGPU guarantees 16 a stage.`, ); return (layer, uv) => texture(maps[layer] as Texture, uv).rgb; }; @@ -345,5 +445,9 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi const t = normalSample.mul(2).sub(1); const perturbed = tangent.mul(t.x).add(bitangent.mul(t.y)).add(n.mul(t.z)).normalize(); material.normalNode = cameraViewMatrix.mul(vec4(perturbed, 0)).xyz.normalize(); + console.info( + `TN_TERRAIN_SPLAT layers=${String(all.length)} samplers=${String(samplers)} ` + + `stacked=${String(stackedSets)}`, + ); return material; } diff --git a/packages/create-threenative/agent-docs/references/capability-reference.md b/packages/create-threenative/agent-docs/references/capability-reference.md index 6789a58f6e..81df2af05a 100644 --- a/packages/create-threenative/agent-docs/references/capability-reference.md +++ b/packages/create-threenative/agent-docs/references/capability-reference.md @@ -2924,11 +2924,11 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi ``` - **Use when:** terrain textured by splat masks exported from Blender with the world package · the game's terrain should match the DCC's terrain material without a second copy -- **Constraints:** the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by the `export_terrain_layers.py` recipe · WebGPU allows 16 sampled textures per stage: planes + diffuse maps + normal maps must fit +- **Constraints:** the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by `export_terrain_layers` in the `export_world.py` recipe · WebGPU allows 16 sampled textures per stage: same-size layers stack into one array texture per set, and `TN_TERRAIN_SPLAT samplers=` reports what is left - **Overrides:** every value comes from the package's table; the returned material is the game's to adjust ```ts -const surface = await loadTerrainSplat({ assets: ctx.assets, url: "world/world.json" }); +const surface = await loadTerrainSplat({ assets: ctx.assets, renderer: ctx.renderer, url: "world/world.json" }); const world = await WorldCells.load({ assets: ctx.assets, url: "world/world.json", surface, follow, ring: 2 }); ``` diff --git a/packages/create-threenative/capabilities.json b/packages/create-threenative/capabilities.json index a6686b28b8..bb63d81701 100644 --- a/packages/create-threenative/capabilities.json +++ b/packages/create-threenative/capabilities.json @@ -3730,10 +3730,10 @@ { "aliases": [], "constraints": [ - "the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by the `export_terrain_layers.py` recipe", - "WebGPU allows 16 sampled textures per stage: planes + diffuse maps + normal maps must fit" + "the package's world.json must carry `terrain.layers.table` and `terrain.layers.splat`, written by `export_terrain_layers` in the `export_world.py` recipe", + "WebGPU allows 16 sampled textures per stage: same-size layers stack into one array texture per set, and `TN_TERRAIN_SPLAT samplers=` reports what is left" ], - "example": "const surface = await loadTerrainSplat({ assets: ctx.assets, url: \"world/world.json\" });\nconst world = await WorldCells.load({ assets: ctx.assets, url: \"world/world.json\", surface, follow, ring: 2 });", + "example": "const surface = await loadTerrainSplat({ assets: ctx.assets, renderer: ctx.renderer, url: \"world/world.json\" });\nconst world = await WorldCells.load({ assets: ctx.assets, url: \"world/world.json\", surface, follow, ring: 2 });", "importPath": "@threenative/core/world", "kind": "function", "overrides": [ From ab6b7785221e7d89f5cc84a87ee0e2e857759b96 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 4 Oct 2026 11:13:43 -0700 Subject: [PATCH 2/6] feat(world): per-layer ORM on the splat surface (PRD-493 phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The material hard-coded metalness 0 / roughness 0.92 and gave every layer it, so a Megascans-grade layer's own occlusion, roughness and metalness never reached the screen. A layer with "orm": true in the table now reads _orm.jpg as linear data (r occlusion, g roughness, b metalness) through the same stacked array and the same blend weights as its colour; a layer without one uses the roughness and metalness the table states. A layer that states neither now throws and names the recipe, rather than falling back to a number in the package — the constant is gone. The recipe writes _orm beside _diff and _nrm in both copies of export_world.py, and refuses a table whose ORM layer names no orm source. --- ...93-terrain-layers-past-sixteen-textures.md | 18 ++- .../__tests__/export-world.spec.ts | 126 ++++++++++++++++++ .../blender-mcp/gpl/recipes/export_world.py | 13 +- .../__tests__/world-terrain-splat.spec.ts | 76 ++++++++++- packages/core/capabilities.json | 2 +- packages/core/gpl/recipes/export_world.py | 13 +- packages/core/src/world-terrain-splat.ts | 54 ++++++-- .../references/capability-reference.md | 2 +- packages/create-threenative/capabilities.json | 2 +- 9 files changed, 284 insertions(+), 22 deletions(-) diff --git a/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md b/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md index a4dc093b31..c7e26b16b9 100644 --- a/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md +++ b/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md @@ -48,10 +48,22 @@ The blend curve keeps its current form (mask, `lo`/`hi`, breakup push, macro noi Uncompressed layers stack through `renderer.copyTextureToTexture` per layer (`ILoadTerrainSplatOptions.renderer`, so a game passes `ctx.renderer`); the array itself is created with `source.dataReady = false`, so no CPU pixel read and no zero upload stand in for a copy. Without a renderer every layer keeps its own sampler and the marker says what that costs. #### Phase 2: ORM per layer -**Status:** NOT STARTED +**Status:** LANDED **Files:** `packages/core/src/world-terrain-splat.ts`, `packages/blender-mcp/gpl/recipes/export_world.py`, `packages/core/gpl/recipes/export_world.py` -- [ ] A table layer with `orm: true` drives roughness, AO and metalness from its map. A layer without it uses the table's values, and no numeric look constant remains in `loadTerrainSplat`. proof: red-green `world-terrain-splat.spec.ts` case. -- [ ] The recipe writes `_orm.jpg` for a layer whose material has a roughness input. proof: a red-green case in `pnpm exec vitest run packages/blender-mcp/__tests__/export-world.spec.ts`; Blender is installed here, so the case runs and does not skip. +- [x] A table layer with `orm: true` drives roughness, AO and metalness from its map. A layer without it uses the table's values, and no numeric look constant remains in `loadTerrainSplat`. proof: red-green `world-terrain-splat.spec.ts` — 4 red (no `_orm.jpg` request, no `aoNode`/`roughnessNode`/`metalnessNode`), then 11/11 green exit 0. `MeshStandardNodeMaterial()` now takes no `metalness: 0, roughness: 0.92`, the three nodes carry the blended ORM chain, and a layer that states neither an ORM map nor both numbers throws before any texture loads. +- [x] The recipe writes `_orm.jpg` for a layer whose material has a roughness input. proof: red-green `pnpm exec vitest run packages/blender-mcp/__tests__/export-world.spec.ts` — with the recipe change stashed the case saw 3 files where it wanted 4 (red), then 9/9 green exit 0 with Blender 5.2.0 LTS (not skipped: no `TN_BLENDER_TESTS_SKIPPED`). A second case proves a table naming no `orm` source fails closed. Both copies of `export_world.py` remain byte-identical. + +## Decisions + +- **2026-10-04 — the recipe keys ORM off the table's `orm` flag, not a Blender material lookup.** The + table is the file the DCC's own terrain shader already reads, and it is what `normal` has always + keyed off, so a second source of truth (a material named after the layer id) would be a new + convention to keep in sync. The recipe fails closed when a layer asks for an ORM map and the table + names no `orm` source. +- **2026-10-04 — a layer with neither `orm` nor `roughness`/`metalness` throws.** The alternative was + a neutral default inside the package, which is the look constant this PRD exists to remove; a table + written before ORM now fails with a message naming the recipe instead of silently changing how every + layer catches the light. #### Phase 3: Native and mobile sample the arrays **Status:** NOT STARTED diff --git a/packages/blender-mcp/__tests__/export-world.spec.ts b/packages/blender-mcp/__tests__/export-world.spec.ts index 7bd3a23338..c7d8f25656 100644 --- a/packages/blender-mcp/__tests__/export-world.spec.ts +++ b/packages/blender-mcp/__tests__/export-world.spec.ts @@ -4,6 +4,7 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; +import { rgbaPng } from "../../../test-support/png.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; import { type IWorldPackage, validateWorldPackage } from "../../core/src/world-package.js"; import { resolveBlender } from "../src/detect.js"; @@ -28,6 +29,7 @@ if (!blender.available) { const withBlender = blender.available ? describe : describe.skip; const fixtureScript = path.resolve("packages/blender-mcp/gpl/fixtures/make_world_fixture.py"); +const recipeScript = path.resolve("packages/blender-mcp/gpl/recipes/export_world.py"); const COUNT_SNIPPET = `import bpy, json bpy.ops.wm.open_mainfile(filepath=__BLEND__) @@ -378,3 +380,127 @@ bpy.ops.wm.save_as_mainfile(filepath=__OUT__) expect(readdirSync(outside)).toEqual([]); }, 300_000); }); + +withBlender("export_terrain_layers against a real Blender", () => { + /** + * The layer table a game hands the recipe, with the DCC's own map files beside it. The recipe + * copies map bytes verbatim, so only the mask has to be an image Blender can read. + */ + async function terrainTable(root: string): Promise { + const dcc = path.join(root, "dcc"); + mkdirSync(dcc, { recursive: true }); + await writeFile( + path.join(dcc, "moss-mask.png"), + rgbaPng({ + blue: () => 255, + green: () => 255, + height: 8, + red: (x, y) => (x * 32 + y * 16) % 256, + width: 8, + }), + ); + for (const name of ["moss_diff.jpg", "moss_nrm.jpg", "moss_orm.jpg", "rock_diff.jpg"]) + await writeFile(path.join(dcc, name), `TN_DCC_TEXTURE ${name}`); + const table = path.join(dcc, "layers.json"); + await writeFile( + table, + JSON.stringify({ + base: { + id: "moss", + metalness: 0, + normal: true, + orm: true, + roughness: 0.9, + tile: 3, + tint: [0.36, 0.47, 0.27], + }, + breakup: { push: 0.2, scale: 0.3 }, + layers: [ + { + channel: "r", + hi: 0.8, + id: "rock", + lo: 0.2, + mask: "moss-mask", + metalness: 0.1, + roughness: 0.7, + tile: 6, + tint: [1, 1, 1], + }, + ], + macro: { max: 1.1, min: 0.9, scale: 0.05 }, + masks: { "moss-mask": { image: "moss-mask.png" } }, + splatSize: 8, + textures: { diff: "{id}_diff.jpg", nrm: "{id}_nrm.jpg", orm: "{id}_orm.jpg" }, + }), + ); + return table; + } + + it("writes the ORM map of a layer whose material has a roughness input, beside its other maps", async () => { + const root = await makeTempDir("tn-export-terrain-layers-"); + const blend = await makeFixtureBlend(root); + const out = path.join(root, "package"); + await blenderRun(root, [ + "-b", + blend, + "--python", + recipeScript, + "--", + "--out", + out, + "--cell", + "64", + "--terrain-layers", + await terrainTable(root), + ]); + const tex = path.join(out, "terrain/tex"); + expect(jsonFiles(tex)).toEqual([ + "moss_diff.jpg", + "moss_nrm.jpg", + "moss_orm.jpg", + "rock_diff.jpg", + ]); + expect(readFileSync(path.join(tex, "moss_orm.jpg"), "utf8")).toBe( + "TN_DCC_TEXTURE moss_orm.jpg", + ); + // The shipped table carries each layer's light response: the ORM flag, or its two numbers. + const shipped = JSON.parse(readFileSync(path.join(out, "terrain/layers.json"), "utf8")) as { + base: { orm?: boolean }; + layers: { roughness: number }[]; + }; + expect(shipped.base.orm).toBe(true); + expect(shipped.layers[0]?.roughness).toBe(0.7); + expect( + JSON.parse(readFileSync(path.join(out, "world.json"), "utf8")).terrain.layers.table, + ).toBe("terrain/layers.json"); + }, 300_000); + + it("refuses a table whose ORM layer names no orm source texture", async () => { + const root = await makeTempDir("tn-export-terrain-layers-noorm-"); + const blend = await makeFixtureBlend(root); + const out = path.join(root, "package"); + const table = await terrainTable(root); + const authored = JSON.parse(readFileSync(table, "utf8")) as { + textures: Record; + }; + authored.textures = { diff: "{id}_diff.jpg", nrm: "{id}_nrm.jpg" }; + await writeFile(table, JSON.stringify(authored)); + await expect( + blenderRun(root, [ + "-b", + blend, + "--python", + recipeScript, + "--", + "--out", + out, + "--cell", + "64", + "--terrain-layers", + table, + ]), + ).rejects.toThrow(); + expect(existsSync(path.join(out, "terrain/tex/moss_orm.jpg"))).toBe(false); + }, 300_000); +}); diff --git a/packages/blender-mcp/gpl/recipes/export_world.py b/packages/blender-mcp/gpl/recipes/export_world.py index fab5f39822..45ea7d3eb1 100644 --- a/packages/blender-mcp/gpl/recipes/export_world.py +++ b/packages/blender-mcp/gpl/recipes/export_world.py @@ -388,6 +388,8 @@ def skipped_collections(layer, under_hidden=False): def terrain_layer_texture(table_dir, textures, layer_id, kind): """The source file of one texture set's map, searched in the table's `textures.search` dirs.""" + if kind not in textures: + fail("terrain layers: the table names no '%s' texture for a layer that asks for one" % kind) name = textures[kind].replace("{id}", layer_id) for relative in textures.get("search", ["."]): base = os.path.normpath(os.path.join(table_dir, relative)) @@ -422,7 +424,9 @@ def export_terrain_layers(out, table_path, root=None): The table (the game's; its DCC shader can read the same file) names its masks, texture sets and blend values. This writes `terrain/layers.json` (the table plus the splat layout), `terrain/splat.rgba8` (masks packed into RGBA8 planes, raw so no cook converts them) and - `terrain/tex/_diff.jpg` / `_nrm.jpg`, and returns the `terrain.layers` paths. + `terrain/tex/_diff.jpg`, `_nrm.jpg` and `_orm.jpg`, and returns the + `terrain.layers` paths. A layer's `normal` and `orm` flags say which maps it ships, and its + `roughness`/`metalness` say how it answers light when it ships no ORM map. Relative paths in the table resolve against `root` when given, else the table's folder. """ import numpy as np @@ -476,6 +480,13 @@ def export_terrain_layers(out, table_path, root=None): terrain_layer_texture(table_dir, textures, layer["id"], "nrm"), os.path.join(tex, "%s_nrm.jpg" % layer["id"]), ) + if layer.get("orm"): + # A material with a roughness input of its own ships that input as occlusion, + # roughness and metalness; the table's numbers stand in for one without it. + shutil.copyfile( + terrain_layer_texture(table_dir, textures, layer["id"], "orm"), + os.path.join(tex, "%s_orm.jpg" % layer["id"]), + ) shipped = {key: table[key] for key in ("base", "breakup", "macro", "layers")} shipped["splat"] = {"masks": placed, "planes": len(planes), "size": size} diff --git a/packages/core/__tests__/world-terrain-splat.spec.ts b/packages/core/__tests__/world-terrain-splat.spec.ts index 159fb8a109..79aeeffb81 100644 --- a/packages/core/__tests__/world-terrain-splat.spec.ts +++ b/packages/core/__tests__/world-terrain-splat.spec.ts @@ -3,18 +3,39 @@ import { MeshStandardNodeMaterial } from "three/webgpu"; import { afterEach, describe, expect, it, vi } from "vitest"; import { loadTerrainSplat, stackLayers } from "../src/world-terrain-splat.js"; +/** Every layer states how it responds to light: an `orm` map, or the two numbers themselves. */ const table = { - base: { id: "moss", normal: true, tile: 3, tint: [0.36, 0.47, 0.27] }, + base: { + id: "moss", + metalness: 0, + normal: true, + roughness: 0.9, + tile: 3, + tint: [0.36, 0.47, 0.27], + }, breakup: { push: 0.25, scale: 0.35 }, layers: [ - { channel: "r", hi: 1.35, id: "litter", lo: 0.3, mask: "litter", tile: 3, tint: [1, 1, 1] }, + { + channel: "r", + hi: 1.35, + id: "litter", + lo: 0.3, + mask: "litter", + metalness: 0, + roughness: 0.95, + tile: 3, + tint: [1, 1, 1], + }, { channel: "b", hi: 0.75, id: "grass", lo: 0.25, mask: "b", + metalness: 0, normal: true, + orm: true, + roughness: 0.8, tile: 3.5, tint: [1, 1, 1], }, @@ -24,6 +45,8 @@ const table = { id: "rock", lo: 0.45, mask: "a", + metalness: 0.2, + roughness: 0.6, tile: 7, tint: [1, 1, 1], triplanar: true, @@ -99,6 +122,9 @@ function sampledTextures(material: MeshStandardNodeMaterial): Set { }; walk(material.colorNode as never); walk(material.normalNode as never); + walk(material.roughnessNode as never); + walk(material.aoNode as never); + walk(material.metalnessNode as never); return found; } @@ -118,11 +144,12 @@ describe("loadTerrainSplat", () => { expect(surface).toBeInstanceOf(MeshStandardNodeMaterial); expect((surface as MeshStandardNodeMaterial).colorNode).toBeTruthy(); expect((surface as MeshStandardNodeMaterial).normalNode).toBeTruthy(); - // Every set's albedo, and a normal only where the table asks for one. + // Every set's albedo, a normal and an ORM map only where the table asks for one. expect(requested.sort()).toEqual( [ "world/terrain/tex/grass_diff.jpg", "world/terrain/tex/grass_nrm.jpg", + "world/terrain/tex/grass_orm.jpg", "world/terrain/tex/litter_diff.jpg", "world/terrain/tex/moss_diff.jpg", "world/terrain/tex/moss_nrm.jpg", @@ -131,6 +158,41 @@ describe("loadTerrainSplat", () => { ); }); + it("drives roughness, occlusion and metalness from the table and its ORM maps", async () => { + const { assets } = served({ + "world/world.json": world({ splat: "terrain/splat.rgba8", table: "terrain/layers.json" }), + "world/terrain/layers.json": table, + "world/terrain/splat.rgba8": new Uint8Array(2 * 4 * 4 * 4), + }); + const surface = (await loadTerrainSplat({ + assets, + renderer: copyingRenderer() as never, + url: "world/world.json", + })) as MeshStandardNodeMaterial; + // The surface's whole light response comes from the table: no scalar left in the package. + expect(surface.roughnessNode).toBeTruthy(); + expect(surface.aoNode).toBeTruthy(); + expect(surface.metalnessNode).toBeTruthy(); + expect(surface.roughness).toBe(1); + expect(surface.metalness).toBe(0); + // splat + albedo + normal + the single ORM layer's array. + expect(sampledTextures(surface).size).toBe(4); + }); + + it("refuses a layer that states neither an ORM map nor its own roughness and metalness", async () => { + const { assets } = served({ + "world/world.json": world({ splat: "terrain/splat.rgba8", table: "terrain/layers.json" }), + "world/terrain/layers.json": { + ...table, + layers: [{ channel: "r", hi: 1, id: "rock", lo: 0, mask: "a", tile: 7, tint: [1, 1, 1] }], + }, + "world/terrain/splat.rgba8": new Uint8Array(2 * 4 * 4 * 4), + }); + await expect(loadTerrainSplat({ assets, url: "world/world.json" })).rejects.toThrow( + /'rock' declares no orm map/u, + ); + }); + it("refuses a package without terrain layers instead of drawing a default", async () => { const { assets } = served({ "world/world.json": world(undefined) }); await expect(loadTerrainSplat({ assets, url: "world/world.json" })).rejects.toThrow( @@ -159,7 +221,9 @@ describe("loadTerrainSplat", () => { id: `layer-${String(index)}`, lo: 0, mask: "a", + metalness: 0, normal: true, + roughness: 0.7, tile: 3, tint: [1, 1, 1], })), @@ -207,9 +271,9 @@ describe("loadTerrainSplat", () => { renderer: copyingRenderer() as never, url: "world/world.json", })) as MeshStandardNodeMaterial; - // splat + 4 albedos (one layer's size the rest do not share) + 1 stacked normal set. - expect(sampledTextures(surface).size).toBe(6); - expect(info).toHaveBeenCalledWith("TN_TERRAIN_SPLAT layers=4 samplers=6 stacked=1"); + // splat + 4 albedos (one layer's size the rest do not share) + a stacked normal and ORM set. + expect(sampledTextures(surface).size).toBe(7); + expect(info).toHaveBeenCalledWith("TN_TERRAIN_SPLAT layers=4 samplers=7 stacked=2"); expect(warn).toHaveBeenCalledWith(expect.stringContaining("bind 4 samplers")); }); }); diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index bb63d81701..e6f9776372 100644 --- a/packages/core/capabilities.json +++ b/packages/core/capabilities.json @@ -3745,7 +3745,7 @@ "terrain textured by splat masks exported from Blender with the world package", "the game's terrain should match the DCC's terrain material without a second copy" ], - "summary": "The splat terrain surface a world package describes, for `WorldCells.load({ surface })`. Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries a normal map. Nothing here is a look choice: textures, tiles, tints, thresholds and noise scales all come from the package's table, which the game authors once and its DCC shares.", + "summary": "The splat terrain surface a world package describes, for `WorldCells.load({ surface })`. Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries a normal map. Each layer answers light from its own ORM map where the table ships one and from the table's own roughness and metalness where it does not. Nothing here is a look choice: textures, tiles, tints, thresholds, surface response and noise scales all come from the package's table, which the game authors once and its DCC shares.", "supersedes": [], "symbol": "loadTerrainSplat" }, diff --git a/packages/core/gpl/recipes/export_world.py b/packages/core/gpl/recipes/export_world.py index fab5f39822..45ea7d3eb1 100644 --- a/packages/core/gpl/recipes/export_world.py +++ b/packages/core/gpl/recipes/export_world.py @@ -388,6 +388,8 @@ def skipped_collections(layer, under_hidden=False): def terrain_layer_texture(table_dir, textures, layer_id, kind): """The source file of one texture set's map, searched in the table's `textures.search` dirs.""" + if kind not in textures: + fail("terrain layers: the table names no '%s' texture for a layer that asks for one" % kind) name = textures[kind].replace("{id}", layer_id) for relative in textures.get("search", ["."]): base = os.path.normpath(os.path.join(table_dir, relative)) @@ -422,7 +424,9 @@ def export_terrain_layers(out, table_path, root=None): The table (the game's; its DCC shader can read the same file) names its masks, texture sets and blend values. This writes `terrain/layers.json` (the table plus the splat layout), `terrain/splat.rgba8` (masks packed into RGBA8 planes, raw so no cook converts them) and - `terrain/tex/_diff.jpg` / `_nrm.jpg`, and returns the `terrain.layers` paths. + `terrain/tex/_diff.jpg`, `_nrm.jpg` and `_orm.jpg`, and returns the + `terrain.layers` paths. A layer's `normal` and `orm` flags say which maps it ships, and its + `roughness`/`metalness` say how it answers light when it ships no ORM map. Relative paths in the table resolve against `root` when given, else the table's folder. """ import numpy as np @@ -476,6 +480,13 @@ def export_terrain_layers(out, table_path, root=None): terrain_layer_texture(table_dir, textures, layer["id"], "nrm"), os.path.join(tex, "%s_nrm.jpg" % layer["id"]), ) + if layer.get("orm"): + # A material with a roughness input of its own ships that input as occlusion, + # roughness and metalness; the table's numbers stand in for one without it. + shutil.copyfile( + terrain_layer_texture(table_dir, textures, layer["id"], "orm"), + os.path.join(tex, "%s_orm.jpg" % layer["id"]), + ) shipped = {key: table[key] for key in ("base", "breakup", "macro", "layers")} shipped["splat"] = {"masks": placed, "planes": len(planes), "size": size} diff --git a/packages/core/src/world-terrain-splat.ts b/packages/core/src/world-terrain-splat.ts index 95201c672f..abeb11a7f2 100644 --- a/packages/core/src/world-terrain-splat.ts +++ b/packages/core/src/world-terrain-splat.ts @@ -35,12 +35,17 @@ import type { IWorldExtent } from "./world-package.js"; type Channel = "r" | "g" | "b" | "a"; -/** One texture set: albedo (and optionally a normal map) tiled in metres, tinted in linear. */ +/** One texture set: albedo (and optionally a normal and ORM map) tiled in metres, tinted in linear. */ export interface ITerrainSplatLayer { readonly id: string; readonly tile: number; readonly tint: readonly [number, number, number]; readonly normal?: boolean; + /** `_orm.jpg`: occlusion in r, roughness in g, metalness in b, read as linear data. */ + readonly orm?: boolean; + /** Without an ORM map, how this layer answers light. Required unless `orm` is true. */ + readonly metalness?: number; + readonly roughness?: number; readonly saturation?: number; /** Box-projected, for cliffs a top-down projection smears. */ readonly triplanar?: boolean; @@ -76,7 +81,7 @@ export interface ITerrainSplatTable { readonly planes: number; readonly masks: Readonly>; }; - /** Package-relative folder of `_diff.jpg` and `_nrm.jpg`. */ + /** Package-relative folder of `_diff.jpg`, `_nrm.jpg` and `_orm.jpg`. */ readonly textures: string; } @@ -294,8 +299,10 @@ export function stackLayers( * heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness * variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up * authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries - * a normal map. Nothing here is a look choice: textures, tiles, tints, thresholds and noise scales - * all come from the package's table, which the game authors once and its DCC shares. + * a normal map. Each layer answers light from its own ORM map where the table ships one and from + * the table's own roughness and metalness where it does not. Nothing here is a look choice: + * textures, tiles, tints, thresholds, surface response and noise scales all come from the package's + * table, which the game authors once and its DCC shares. * * @situation terrain textured by splat masks exported from Blender with the world package * @situation the game's terrain should match the DCC's terrain material without a second copy @@ -335,9 +342,22 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi for (let plane = 0; plane < table.splat.planes; plane += 1) planes.push(texture(splat, maskUv).depth(int(plane))); - const load = async (layer: ITerrainSplatLayer, kind: "diff" | "nrm"): Promise => { + const all: ITerrainSplatLayer[] = [table.base, ...table.layers]; + for (const layer of all) + if ( + layer.orm !== true && + (typeof layer.roughness !== "number" || typeof layer.metalness !== "number") + ) + throw new Error( + `loadTerrainSplat: terrain layer '${layer.id}' declares no orm map and no roughness/metalness; give it "orm": true or both numbers in the table the export recipe wrote.`, + ); + + const load = async ( + layer: ITerrainSplatLayer, + kind: "diff" | "nrm" | "orm", + ): Promise => { const map = await assets.texture(`${dir}${table.textures}/${layer.id}_${kind}.jpg`, { - data: kind === "nrm", + data: kind !== "diff", }); map.wrapS = RepeatWrapping; map.wrapT = RepeatWrapping; @@ -345,10 +365,11 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi map.needsUpdate = true; return map; }; - const all: ITerrainSplatLayer[] = [table.base, ...table.layers]; const withNormals = all.filter((layer) => layer.normal === true); + const withOrm = all.filter((layer) => layer.orm === true); const diffuseMaps = await Promise.all(all.map((layer) => load(layer, "diff"))); const normalMaps = await Promise.all(withNormals.map((layer) => load(layer, "nrm"))); + const ormMaps = await Promise.all(withOrm.map((layer) => load(layer, "orm"))); // One sampler per set when the layers stack; separate textures (and a warning) when they do not. let samplers = 1; @@ -372,6 +393,7 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi }; const diffuseAt = sampler(diffuseMaps, "albedo"); const normalAt = withNormals.length === 0 ? undefined : sampler(normalMaps, "normal"); + const ormAt = withOrm.length === 0 ? undefined : sampler(ormMaps, "ORM"); const ground = vec2(positionWorld.x, positionWorld.z.negate()); const maskOf = (layer: ITerrainSplatMaskedLayer): Node<"float"> => { @@ -412,9 +434,19 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi ? undefined : normalAt(slot, ground.div(layer.tile)); }; + /** + * A layer's answer to light, as occlusion in r, roughness in g and metalness in b: its ORM map + * where it ships one, else the two numbers the table states (and no occlusion to apply). + */ + const ormOf = (layer: ITerrainSplatLayer): Node<"vec3"> => { + const slot = withOrm.indexOf(layer); + if (slot !== -1 && ormAt !== undefined) return ormAt(slot, ground.div(layer.tile)); + return vec3(1, layer.roughness ?? 0, layer.metalness ?? 0); + }; let color = albedo(table.base, 0); let normalSample: Node<"vec3"> = normalOf(table.base) ?? vec3(0.5, 0.5, 1); + let ormSample: Node<"vec3"> = ormOf(table.base); table.layers.forEach((layer, offset) => { const weight = clamp( maskOf(layer) @@ -427,6 +459,7 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi color = mix(color, albedo(layer, offset + 1), weight); const nrm = normalOf(layer); if (nrm !== undefined) normalSample = mix(normalSample, nrm, weight); + ormSample = mix(ormSample, ormOf(layer), weight); }); const macro = mx_noise_float(vec3(ground.mul(table.macro.scale), 0)) .mul(0.5) @@ -434,7 +467,9 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi .mul(table.macro.max - table.macro.min) .add(table.macro.min); - const material = new MeshStandardNodeMaterial({ metalness: 0, roughness: 0.92 }); + // Every layer's own light response, blended by the same weights as its colour: the material + // carries no surface constant of its own. + const material = new MeshStandardNodeMaterial(); material.name = "terrain-splat"; material.colorNode = color.mul(macro); // A top-down projection's tangent frame: +x along u, ground +y (three -z) along v, OpenGL green @@ -445,6 +480,9 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi const t = normalSample.mul(2).sub(1); const perturbed = tangent.mul(t.x).add(bitangent.mul(t.y)).add(n.mul(t.z)).normalize(); material.normalNode = cameraViewMatrix.mul(vec4(perturbed, 0)).xyz.normalize(); + material.aoNode = ormSample.r; + material.roughnessNode = ormSample.g; + material.metalnessNode = ormSample.b; console.info( `TN_TERRAIN_SPLAT layers=${String(all.length)} samplers=${String(samplers)} ` + `stacked=${String(stackedSets)}`, diff --git a/packages/create-threenative/agent-docs/references/capability-reference.md b/packages/create-threenative/agent-docs/references/capability-reference.md index 81df2af05a..4b65a8776e 100644 --- a/packages/create-threenative/agent-docs/references/capability-reference.md +++ b/packages/create-threenative/agent-docs/references/capability-reference.md @@ -2917,7 +2917,7 @@ const sampleHeight = heightSamplerFromHeightmap(terrain, extent, await loadWorld ### `loadTerrainSplat` -`function` — The splat terrain surface a world package describes, for `WorldCells.load({ surface })`. Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries a normal map. Nothing here is a look choice: textures, tiles, tints, thresholds and noise scales all come from the package's table, which the game authors once and its DCC shares. +`function` — The splat terrain surface a world package describes, for `WorldCells.load({ surface })`. Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries a normal map. Each layer answers light from its own ORM map where the table ships one and from the table's own roughness and metalness where it does not. Nothing here is a look choice: textures, tiles, tints, thresholds, surface response and noise scales all come from the package's table, which the game authors once and its DCC shares. ```ts export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promise { … } diff --git a/packages/create-threenative/capabilities.json b/packages/create-threenative/capabilities.json index bb63d81701..e6f9776372 100644 --- a/packages/create-threenative/capabilities.json +++ b/packages/create-threenative/capabilities.json @@ -3745,7 +3745,7 @@ "terrain textured by splat masks exported from Blender with the world package", "the game's terrain should match the DCC's terrain material without a second copy" ], - "summary": "The splat terrain surface a world package describes, for `WorldCells.load({ surface })`. Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries a normal map. Nothing here is a look choice: textures, tiles, tints, thresholds and noise scales all come from the package's table, which the game authors once and its DCC shares.", + "summary": "The splat terrain surface a world package describes, for `WorldCells.load({ surface })`. Layers blend over a base by mask channels read as linear data (the masks ship raw, beside the heightmap, so no cook moves a blend threshold), with noise-broken edges and macro brightness variation. Texture sets tile in world metres on the package's ground plane (x, -z: a Z-up authoring tool's x and y), cliffs can be triplanar, and the base plus any layer that asks carries a normal map. Each layer answers light from its own ORM map where the table ships one and from the table's own roughness and metalness where it does not. Nothing here is a look choice: textures, tiles, tints, thresholds, surface response and noise scales all come from the package's table, which the game authors once and its DCC shares.", "supersedes": [], "symbol": "loadTerrainSplat" }, From 1123968d26ca9351a28b4979a446d82507227059 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 4 Oct 2026 11:58:38 -0700 Subject: [PATCH 3/6] test(world): a 16-layer uncompressed splat package on a GPU, plus its captures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit specs prove the mechanism in node; this example proves it on a real WebGPU adapter. The package is sixteen layers with an albedo, a normal and an ORM map each — 48 uncompressed JPEGs — written by the shipped export_terrain_layers recipe from a table and map files a game would author, so the thing under test is a DCC export, not a second format. The run is green: TN_TERRAIN_SPLAT layers=16 samplers=4 stacked=3, 180 frames, a non-blank changing viewport, and the scenario asserts those numbers through material.userData rather than trusting a screenshot. The same package on develop never reaches a frame: Uncaptured WebGPU GPUValidationError: The number of samplers (34) in the Fragment stage exceeds the maximum per-stage limit (16). Captures for the judge, same package and same pose: .afk/scratch/prd493-captures/phase-1-2/{before,after}.png (4 KB uniform vs a textured 16-band surface). Not a Machinefall result, so AC-1 stays open. --- biome.json | 1 + examples/prd493-terrain-splat/README.md | 49 +++ examples/prd493-terrain-splat/index.html | 17 + examples/prd493-terrain-splat/package.json | 22 ++ .../playtests/terrain-splat.playtest.json | 64 ++++ .../public/world/placements.bin | 0 .../public/world/terrain/heightmap.u16 | Bin 0 -> 8192 bytes .../public/world/terrain/layers.json | 308 ++++++++++++++++++ .../public/world/terrain/splat.rgba8 | Bin 0 -> 327680 bytes .../world/terrain/tex/layer-00_diff.jpg | Bin 0 -> 953 bytes .../public/world/terrain/tex/layer-00_nrm.jpg | Bin 0 -> 2092 bytes .../public/world/terrain/tex/layer-00_orm.jpg | Bin 0 -> 2294 bytes .../world/terrain/tex/layer-01_diff.jpg | Bin 0 -> 1824 bytes .../public/world/terrain/tex/layer-01_nrm.jpg | Bin 0 -> 2114 bytes .../public/world/terrain/tex/layer-01_orm.jpg | Bin 0 -> 2307 bytes .../world/terrain/tex/layer-02_diff.jpg | Bin 0 -> 1804 bytes .../public/world/terrain/tex/layer-02_nrm.jpg | Bin 0 -> 2133 bytes .../public/world/terrain/tex/layer-02_orm.jpg | Bin 0 -> 2333 bytes .../world/terrain/tex/layer-03_diff.jpg | Bin 0 -> 996 bytes .../public/world/terrain/tex/layer-03_nrm.jpg | Bin 0 -> 2162 bytes .../public/world/terrain/tex/layer-03_orm.jpg | Bin 0 -> 2382 bytes .../world/terrain/tex/layer-04_diff.jpg | Bin 0 -> 1866 bytes .../public/world/terrain/tex/layer-04_nrm.jpg | Bin 0 -> 2181 bytes .../public/world/terrain/tex/layer-04_orm.jpg | Bin 0 -> 2431 bytes .../world/terrain/tex/layer-05_diff.jpg | Bin 0 -> 1671 bytes .../public/world/terrain/tex/layer-05_nrm.jpg | Bin 0 -> 2230 bytes .../public/world/terrain/tex/layer-05_orm.jpg | Bin 0 -> 2474 bytes .../world/terrain/tex/layer-06_diff.jpg | Bin 0 -> 982 bytes .../public/world/terrain/tex/layer-06_nrm.jpg | Bin 0 -> 2092 bytes .../public/world/terrain/tex/layer-06_orm.jpg | Bin 0 -> 2300 bytes .../world/terrain/tex/layer-07_diff.jpg | Bin 0 -> 1809 bytes .../public/world/terrain/tex/layer-07_nrm.jpg | Bin 0 -> 2116 bytes .../public/world/terrain/tex/layer-07_orm.jpg | Bin 0 -> 2310 bytes .../world/terrain/tex/layer-08_diff.jpg | Bin 0 -> 1745 bytes .../public/world/terrain/tex/layer-08_nrm.jpg | Bin 0 -> 2141 bytes .../public/world/terrain/tex/layer-08_orm.jpg | Bin 0 -> 2340 bytes .../world/terrain/tex/layer-09_diff.jpg | Bin 0 -> 982 bytes .../public/world/terrain/tex/layer-09_nrm.jpg | Bin 0 -> 2156 bytes .../public/world/terrain/tex/layer-09_orm.jpg | Bin 0 -> 2377 bytes .../world/terrain/tex/layer-10_diff.jpg | Bin 0 -> 1554 bytes .../public/world/terrain/tex/layer-10_nrm.jpg | Bin 0 -> 2185 bytes .../public/world/terrain/tex/layer-10_orm.jpg | Bin 0 -> 2444 bytes .../world/terrain/tex/layer-11_diff.jpg | Bin 0 -> 1606 bytes .../public/world/terrain/tex/layer-11_nrm.jpg | Bin 0 -> 2210 bytes .../public/world/terrain/tex/layer-11_orm.jpg | Bin 0 -> 2468 bytes .../world/terrain/tex/layer-12_diff.jpg | Bin 0 -> 975 bytes .../public/world/terrain/tex/layer-12_nrm.jpg | Bin 0 -> 2122 bytes .../public/world/terrain/tex/layer-12_orm.jpg | Bin 0 -> 2300 bytes .../world/terrain/tex/layer-13_diff.jpg | Bin 0 -> 1739 bytes .../public/world/terrain/tex/layer-13_nrm.jpg | Bin 0 -> 2126 bytes .../public/world/terrain/tex/layer-13_orm.jpg | Bin 0 -> 2325 bytes .../world/terrain/tex/layer-14_diff.jpg | Bin 0 -> 1794 bytes .../public/world/terrain/tex/layer-14_nrm.jpg | Bin 0 -> 2130 bytes .../public/world/terrain/tex/layer-14_orm.jpg | Bin 0 -> 2363 bytes .../world/terrain/tex/layer-15_diff.jpg | Bin 0 -> 961 bytes .../public/world/terrain/tex/layer-15_nrm.jpg | Bin 0 -> 2164 bytes .../public/world/terrain/tex/layer-15_orm.jpg | Bin 0 -> 2364 bytes .../public/world/world.json | 27 ++ .../scripts/make_world.py | 265 +++++++++++++++ examples/prd493-terrain-splat/src/game.ts | 85 +++++ examples/prd493-terrain-splat/src/main.ts | 3 + .../src/render/terrain-look.ts | 47 +++ examples/prd493-terrain-splat/tsconfig.json | 8 + packages/core/src/world-terrain-splat.ts | 8 +- pnpm-lock.yaml | 22 ++ 65 files changed, 922 insertions(+), 4 deletions(-) create mode 100644 examples/prd493-terrain-splat/README.md create mode 100644 examples/prd493-terrain-splat/index.html create mode 100644 examples/prd493-terrain-splat/package.json create mode 100644 examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json create mode 100644 examples/prd493-terrain-splat/public/world/placements.bin create mode 100644 examples/prd493-terrain-splat/public/world/terrain/heightmap.u16 create mode 100644 examples/prd493-terrain-splat/public/world/terrain/layers.json create mode 100644 examples/prd493-terrain-splat/public/world/terrain/splat.rgba8 create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-14_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-14_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-14_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_diff.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_nrm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_orm.jpg create mode 100644 examples/prd493-terrain-splat/public/world/world.json create mode 100644 examples/prd493-terrain-splat/scripts/make_world.py create mode 100644 examples/prd493-terrain-splat/src/game.ts create mode 100644 examples/prd493-terrain-splat/src/main.ts create mode 100644 examples/prd493-terrain-splat/src/render/terrain-look.ts create mode 100644 examples/prd493-terrain-splat/tsconfig.json diff --git a/biome.json b/biome.json index 619e6fc5b1..d9054e6009 100644 --- a/biome.json +++ b/biome.json @@ -39,6 +39,7 @@ "examples/abyss-vanilla/vite.config.js", "examples/auto-lod/public/**", "examples/abyss-framework/public/**", + "examples/prd493-terrain-splat/public/**", "examples/abyss-framework/vq-assets/.generated/**", "examples/csg-doorway/public/**", "docs/benchmark/sweeps/**", diff --git a/examples/prd493-terrain-splat/README.md b/examples/prd493-terrain-splat/README.md new file mode 100644 index 0000000000..45a875acab --- /dev/null +++ b/examples/prd493-terrain-splat/README.md @@ -0,0 +1,49 @@ +# PRD-493 — terrain splat, sixteen layers + +Sixteen terrain layers, each with an albedo, a normal and an ORM map, over one streamed +heightfield. Every map is an uncompressed JPEG: 48 textures, which is the shape that used to bind +one sampler per map and stop at WebGPU's 16 sampled textures a stage. + +Same-size layers now stack into one array texture per set — `splat + albedo + normal + ORM`, four +sampled textures — and the material prints what it costs: + +```text +TN_TERRAIN_SPLAT layers=16 samplers=4 stacked=3 +``` + +The numbers are also in `material.userData.TN_TERRAIN_SPLAT`, which this scene publishes so the +scenario asserts the cost rather than a screenshot's opinion. + +## Run it + +```sh +pnpm --filter prd493-terrain-splat dev +node packages/playtest/dist/runner/cli.js examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json \ + --url http://127.0.0.1:5193 \ + --server-command "pnpm --filter prd493-terrain-splat dev --host 127.0.0.1 --port 5193 --strictPort" \ + --browser-recipe webgpu --headed +``` + +## Regenerate the world package + +The package is what a DCC export produces, not a second format: the script writes the layer table +and the map files a game would author, then hands them to `export_terrain_layers` in the shipped +`export_world.py` recipe. Blender writes the JPEGs. + +```sh +pnpm --filter prd493-terrain-splat world +``` + +The maps are proof fixtures, not art: values go straight into 8-bit sRGB JPEGs, so the normal and +ORM channels carry the transfer curve of the file format rather than exact numbers. The masks are +five RGB planes in bands across the map, which is what puts a different layer under the camera as +it orbits. + +## What each arm looks like + +`world-terrain-splat.spec.ts` proves the mechanism in node; this package proves it on a GPU. On +`develop` the same package fails before the first frame: + +```text +Uncaptured WebGPU GPUValidationError: The number of samplers (34) in the Fragment stage exceeds the maximum per-stage limit (16). +``` diff --git a/examples/prd493-terrain-splat/index.html b/examples/prd493-terrain-splat/index.html new file mode 100644 index 0000000000..53d9989910 --- /dev/null +++ b/examples/prd493-terrain-splat/index.html @@ -0,0 +1,17 @@ + + + + + + + PRD-493 terrain splat + + + + + + diff --git a/examples/prd493-terrain-splat/package.json b/examples/prd493-terrain-splat/package.json new file mode 100644 index 0000000000..f41366b179 --- /dev/null +++ b/examples/prd493-terrain-splat/package.json @@ -0,0 +1,22 @@ +{ + "name": "prd493-terrain-splat", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "typecheck": "tsc --noEmit -p tsconfig.json", + "world": "blender --background --factory-startup --python scripts/make_world.py -- --out public/world" + }, + "dependencies": { + "@threenative/core": "workspace:*", + "@threenative/playtest": "workspace:*", + "three": "catalog:" + }, + "devDependencies": { + "@types/three": "0.185.3", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json b/examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json new file mode 100644 index 0000000000..7d7328356c --- /dev/null +++ b/examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json @@ -0,0 +1,64 @@ +{ + "name": "terrain-splat-sixteen-layers", + "schemaVersion": 1, + "subject": "sixteen uncompressed terrain layers must reach the screen through four sampled textures", + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 60, + "artifacts": { + "screenshots": "before-after" + }, + "steps": [ + { + "kind": "wait", + "label": "layers-resident", + "waitTicks": 180, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "components": [ + { + "allowTrivial": "Sixteen layers is the whole package: the table ships no more.", + "component": "layers", + "entity": "splat", + "equals": 16 + }, + { + "allowTrivial": "The splat planes plus one array per set: albedo, normal and ORM.", + "component": "samplers", + "entity": "splat", + "lte": 4 + }, + { + "allowTrivial": "One array texture per set, whatever the layer count.", + "component": "stacked", + "entity": "splat", + "equals": 3 + } + ], + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 1280, + "height": 720, + "minNonblankPixelRatio": 0.05 + }, + "frameDiff": { + "minChangedPixelRatio": 0.0005 + } + } + ] + } +} diff --git a/examples/prd493-terrain-splat/public/world/placements.bin b/examples/prd493-terrain-splat/public/world/placements.bin new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/prd493-terrain-splat/public/world/terrain/heightmap.u16 b/examples/prd493-terrain-splat/public/world/terrain/heightmap.u16 new file mode 100644 index 0000000000000000000000000000000000000000..55c786da36df70a484019a232a6ccdfb82307a64 GIT binary patch literal 8192 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 literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8272a35e369db9bc1886327c5fe9c776836c3c17 GIT binary patch literal 2294 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 literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-01_nrm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1dbb7e42e0151c912d0a4ea4e59c4c0aeab2d61a GIT binary patch literal 2114 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 ih}@&l-adA7+EN0w%_XUxVfMo>h+ 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 literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-02_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1a2d0a80d4d67f837d715a63bfcb7e793ab9a059 GIT binary patch literal 2333 zcmbW1do5v%@6!KXLqo?zN8bozj5FS;ZE9|cH8;c>W49iGC@3f(5lB@e zQWcBVKx6;s*nAG45I_o$A`j66?GMe07b!6bS<3Vs{Wx0da-JjS90$m^qp&4)XBYz23BF`t|}rm zG_`i34UI5ZW87YAn|-$X?Oa@mZtfl=&wxOX5)>Rl4L^P&;$&nLEiV52g@hk3UP?{7 zmY$J$ot~Fpz$h##E@9qhRj?maa;mtG1$DwF^-mibTVK3vYk$?zDeCJV5D&f`8Xg(@ z{p0w=nI*~cA1i;ZeqQ^s#RUQ6|A{3#|05Sl#w7=Z%0m^lxFB)~vMrB-!gMX* zDo*|ip|PrZmRAsJ&bjw$TNL%J$cyS>=X#ME27AX0m$uNpk^OgISN|`ve}Vm*YXne| zhsey6M*)sN{Fy|0&q+d3H2>&R{*#>5m9-=rA>qAnPZIji@nXEuqPAVVY52ygv5>d^ z5j0UjUcU9qH%%_pfomXxlR6nhic*;8;mP$IQ=W~WroC0$Se+|1Ru_j~wdXmvD`7mV zIc@2@8~GgKC7Y|-?EP(CoP4^snO)ge0)D^s+;%Q`qYoGLVZO@R+8%e>#zYu0aI7o- zP9Mjv_^00eqvnaC<{v%ir6*&gB4Lukd?O{F)6knEo^x##gile%ZzeKDPcl@Ktpx^` z8}17Zcf>~}Pvqq}(A#sLm(_*jU$brd<)mg48l;mzd zdH7)B3^TrY@<^h~&pzDF7P>&F;vh0HB7gzE6j@TvJ{TCTF%rbOF>cy`rU$dd)~Tfo zcQCxM2DLF(hGUHrOD$wb9u^^AeqwsNkzq##&CayJyozj7GyleiN1s}}J;3ft0Hwn> zMLq02@6nA5Ln3;}ZWav z60NZWFzZ-kNklL=)?33wc@3m4IbNhvbP!zjq=U%6=$F?utjNOnPZGwVfQ-p%Q>$IL z1Fmw>w%y$H;!LN0x1==qeX*`>bj=qr2ylF^TQ+w^FB<7t> z?O4ar0%;y+D>aD>R{84gXfu*v-$!!0ugt4B)v3XPX`UdjDW7SFsMu%=`{CIFkNle| z?D%`#?E60bZ;LYTv?$H?ue@jp5V$wQ^@hTyR+8CMr=%{U$CwznHG5g ztZTkk>ftq1Fl)3iM>}#jd8XFn&6+TIRvTAS5x_Kz*$}-~(wtVoPRGli2WytblhtoZt{hOSnFlY`f~aS-)DHeXI9Cd5M!L3Y z(0rMPi(0liMU1Rhb?bvnP$+DL1v^T2L8^#9N6aW88WS#PHMDn}PIEL28VFzGIk%;Y z*i}!rQ=RJlUZco3M82nIxt|lg`hf?`>#}*9F=lRC6_wDrqdy$RZD%7SGc`r#?}B2+ z;dVVi;C2kz>`H!=>mQ|hYrwPU}=NM$`$lO`T6#DBNed+xonE?;`xx0L#( zMamR4WL~f8kLt`qkt$WgS_39P`za$&VP)k!S2%n5Xa2`C7fxqZj1aTrE`X+frQ)he zVga|Y;`_d|w{;qr@80bV=r8W&bP%bJ>NzX6c+g;GC*`i#z=LcHxz`aA6wB}3Y zhQ^~K8qv?xwDa@5RMP z=?&d+`q;6**GAlGNnPZJ3|w3}kJ%b;WUmq)(mYCVkQm*)3x$dxTKkFvbEE5^5tT@Zk2u@a| z`EoRMB7<>Qoc5ALEwcJL!=T2Lsw?@qE(kkcC*rO@pglOY!QtaAxHR%k|x2R;cnYxB`W63l{vqX*SfX za673=SFfCnty&le6P()^tM-hI&n*o_z4IOT-5xi2TkBoGj9+#^>7hVmf&Q!yt;cFo zEEVeE^7>@ck2jP;7nXW;Yf05O#gt=f;szap`&dXJ$6;n9*FD#>u6g{3miv%kbNDZQ CKO(XK literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_diff.jpg new file mode 100644 index 0000000000000000000000000000000000000000..87b365fcd27d4fb3fbce1f4fc31110d3a0f27c5d GIT binary patch literal 996 zcmex=?Ck7Z99+U&T*4YsVp19;gZ~E@1UVQQ7#f%vl^B==8JPtc{~uwH2fCV-5e$IN z1p`JVW}ti7IXJnvfeN+?FfcJPGc&O;v$C?V09DrlR;@QBE$ z5`HGdRHf`Rrb=&qG zJ9iyAeB|h{<0np@x^(%<)oa&p+`RSh(c>pipFMx^^3}&rpTB(l_Wj4tUm$-mGB88D z1thWhOAzQUCKeWE7Iu)o7@5j}m|2j8Rnd@5$T5&Tu~1masF6d&Y2w0-2RW6EgFc8R z6KQ!#m{`Vr(cu+NC|SQA-3gL@3&uUiZ}%#6UuV-{qvXZX~mv1lisb5fVanw~RN z8jH4?m}xBPI&tRsqAu-VOktoL0}d$+VJ?kD4R{Sh(s8IuV*w9g>%lr47IkSb4~Is9>>2kHkJypMB2%ZFir9^>hO?bN||i745hdnwwN40iVo)}qHedulqC*p zW}7KxerJWkmO3X6LJWANT%q|G4+QKabDn^T+4&ct2jx&-?ZMs9vky z0y@6fgIE9p0RRX%0M#gH45k5t!(o~T9Rxy43!%SZ zqqfdweM7^|`beaai3Q5Y*la5j`Q6U%%yw8>qpi1~Fm@O#I}0motF4HGyA%U!V|EKus3{)rF`!02HWN1M)3Ex!(e!1_kxPHMJ1yzzYH$ zKn((gs;NUYG}P6>+dqTnfV!@R-uB&|uuTCc;ijkc?SIWH(=^*(^Vk62`@!5HJRwsH zf!u7k#mHiZrIj_>(aG6mkE@&458hax0|$Kxfke{LAaZcTUm~M^jEerkH=qkQqx=P!z{OMhSEf&kEeV1es@hLu#h*}Cbp}OiC z+jqnCJOkh-Pj51{|5a0ee_mP5V=XfW{0D>ZgkA*F+;P<6;~LsGvi}Y&^Zz3I7udhK zh5&6S1S}p@7w`c3n*B>yb^e7#i6h_pok|Z@n$(Npe=nA8q3_x}$=R7@|7k@si{)hi zQ%`9dx^1&C*CxxGQ$ws>IS=l zR3V5yxXfh>6?8j)g7#j{Rxd{bk0f$Fd%-5aSdnzwRXL&mrrFIZuyg<$iYnm;D~I^; z!tXOIegle~*d(VRH<*ysYK^v5~noDP+Mjza}S6~r!c zS-X`J$8$jLE@2BN`;WHUK|cCV8cw!WlkdGD-@q*P^IVoa;&UVYTth3`fr`Gv2wvI?jhxpWYKR|{M4h~xPsj3)j7}T_MhE9tq4f{t*Mo~ zj;b974PBDk*&=>i`?K=VRbPIr<$^VK+qC#XTemN^{4v`B3>F^ z*!2@3BVG)@OexM;AWU!zyB7yoV^;*hgRq(H9*I-pkM)1e4!zq=#B?s$;HR-7rrN5U ztEcd7JKAz5WX(BGJTWvGWaYw)79?vWl~<^-FcqVONHQWCq0ntbBllrp}hE ziiqiJqjxagJ+(1fh`~2xlvI<2;vW`732jM8lWQ_d$GV!2hmzZ!EXu~{Bi0ogbv;gq zUV)a6=^?jP@>BbFEUo{L6p6;0BnmM;QSPaQf2;`BGtYh6b>V$mT-L`mF<9W3ouT&d9rLnjL|pCqh!QE zI4Fy{EV_B>S$sk!-}GInloU}a8)&wi=k0!{oYQAWrrU70s>N1dz3g2VZM-AEhJIGs z!mH?iEs1G4WP7Y~gb=c^@V9@u=s|{-c)#A6D9;oHmuiX@ygMhEb>UKn4Yux+!pKdI zb!U1=&DU>7S~~4(9TjJlIpMs;kDU7tMH+Ld>?O*&s3nfKJK=DvL+$0wUgKQ5j4EEe z&v@0>5C#3nSHBN|Eb9Y(`O0H~Iai%oU<;-MGV>L+``sM0-Hp-*u&=fekDS;ix@WHJ p$B!(Fa=d3Ev)N8LQP(Twyfx29n}JQhrpX9w@Y8Ig>1C(}{{+Dq47mUR literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-03_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..645a23151733ccadca2e76333855c446e550b184 GIT binary patch literal 2382 zcmbW1dpOg78^^!11J=oMiex#nekdV7{P;;m`fY5s(UL^MG*S*xQRGm_5w1ZI004N(UI5Mj0tQFGVF&~qAuo?mKq?`Tii${;^&8eI zsjF<-q^_c-rn$uct+`oGOHFO-Z(H>YjZ82m8feq)rpDV1j7^MJgFxiv<&g?VWh7GB zSW`{Y_BV!YcrIqzAo8N7n_qbqP-S)bBk;oKppZ(OpBSA-lLqcgMqoQMCPsPP2Ur0$! zOTTz2H!r{7N@3AeMnz>+bxkdc&AHLU7u>wn+#>9}*VTQ$=fT5A1A{|i$+PFfBX8fm z|1dfx{rG8odgj~g_aAff3yZ5<5CHm5Eb#n~TquxB4hDn5Maz}cR0B@*Bh~aR-x^G>qWvQK@4%A&FS37u{hMn9SPO-K z#e<>%M_}N5cHTV2bO~)MT;p>Re&kTc(#Uc}W}%%!k9u0H^Q3S3c)@bjUh0+OX!Gi$ z8)1>~eu`UH9Yx3HMeWb@S@>Svyns$2ppGWq7j&VU zmyPT6xfq|APdCKZ)>@Uump{heXkO`~R>wvzy%rSMSXuk&p%T0TMDhju8H^1U_0Jy{ z)j4|QNo#SAA$UxK@Z#(!g!YFkFyOOyMocIAY7XoOXI|57Wmo*n$I>L_*pZ$AX~zU5 z;)?_Is#n6(mUoF!8PwzO@h4TukGVXH_tFXPuVX9XU>kww0Qu{meS*w5LHzWesZBin zmCpxT^X9Co2BlcPXLE-1o3QWAZZnR^F=pYM49E(*GoL)g;UPv;wOrU1mU?~5VUd8f z3`jYV^D;pOO!Ge0J4Vh^qCd5+J3gN=cr>WU0Y?z_6(6AV2WX0JJ4q?CVTJs=R%Nc3+fCr-91fBO0~aRTr{xR1h==Z&4>tq^ z?KiQIWK9M?WF1qA6Y!#ywo9Th)DQvI?EJ%Sjc+nQbD%&W>58#@8Fgp7eRe0G?M(_o zu6f>+@3{vgj&Lah>-tSW9i4T|b(2zpD!HaKC9SH!+5w@B3w-?mCay%BVRX0km3mXzvbxcxDu+vr*FBRr`>|+xNz$liQkp-EFOyy)#>Hsy+^wI_1cr7eD8!qtuSsGeoT& zR~oCWYo1Jc>osmEZTqrFiK$$#E!@1_p3^(j{)S3qE%Y|JV~n%^${wvAQf4&P`kT74 zt|Q8JM$7Rl*vt`9v~}%zN^!MK9W{BKAiqlNOufARlEozbXM8d7*2)|A#@fGI_Ya+$ z`6SF9(WM!mR%SFMD^p5)eNrm~gfa0_uR@r6Ubvg+1jo>no7lD-7G1g0^Va7_FW8p8 zap)9W%$F?VM-JUZomzd=H^D32o>)Zui{0zm-V&&EACA`LVvEUo$CDD)tG=a?T#;KU z>lJiNGL(ClmAzq zo!EgKOf(@`AI~c%@bl&bC%(&olcdQ?Lu%V;#hH-OmVSr(eqEiZL?d2WN%}TjV~?_V zNl7EvZamt|%}n-SZq*~yOSI!OZ@w0U_7$>MoAq@%s59&aIP+A<w9XFFL)J%PX_J2K^oRVo+;~W zjOh4Q$G(|7r<$D@vGh8hz-oCueTq)la(PcQapm&jaMP`oSHZOHZztSvzvrYgk6fIs zwF`|_(;u8|{z;8tqOH{Zo=ODfub7PR5V6w>ie>XS9k0c6wIw5YX{{=UeUk5TX&NYT z#^%IpHA6fQi)x!us^PJLW~iEsw!ImywcwZ%JbdqB9dYb8XFNAASAn?lu#nazIzT+q zF;Bs9eu>^#_8dAT*l(|8n0wa8regmyGT=V{CRj+;a!KF3qyhY|$U%W^f!c+EdkLS* HWW)ae7Cu^L literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_diff.jpg new file mode 100644 index 0000000000000000000000000000000000000000..809db8b2e4bb46c3036f7a1cf6490482da3de7db GIT binary patch literal 1866 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 Kro5lfJo_iwmkh}O literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-04_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7788dd5386df2722f309e7d885385f6943346f4b GIT binary patch literal 2431 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 P*ToypQ6RKI45aNZeTWl( literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_nrm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..273d9a8cac591bd54691bbf774ef266d81097d14 GIT binary patch literal 2230 zcmbW1X;_ol702HYAi$_$4~sJln~**iws2(K?5St zlr>~gN(50L;sRs{A_E~Hqyb@Ag9ZgLjS-k&=V`z6L)&wod%oQB+~@zh=YQ_@>+Lsy zst4K~4L~3O0FfLC$t^NJ}?~V7FAyG(Og98T-m>)DXN1E?k0)fF`DoQFE zDk>W0dfIyC|8s0V1=N&)E5H>f#0Zd6gFw|F+dY7ROtk{!Ysln&4Tv06rWdB9tn!Vl zpiLE!gFvBj@=yf@d3jm&W!X6(ucn}GXzi@1;TsAwir2KsE~!y6c4>L0H~Fj^{6Wdi&Tv_j5+Lqr9>4 z*AtU7v%k*GFDx!C3qP!_f86*a{)goA4i^M~{u4`<|09>0j7v^l9x4yp;eyDe$Tn0> zUct~>QQg@W78|bF2 z=9&a{K_Rl{LDc{!VC+e8AwAAu5Rdz77=6-Q5K@mO%&^PDLre-p+&Nk@UCQBg`7)dM z*$bcxVYv~mI&Dq#m3C0%wmvlvZr!Iob*=P(qc6_HwPDe@6cPO(TRJ&zk_!F+Ss3M# z?t8&?O$#EXLPT`SfNdZ>=$R`i(BFJLcH}P%;fr7Odzdi@agtZOl*28^6g35BF=)BG zlB4ftua)J_HCc`?(6JF`n$8sne8^o1U=Qa={m~JpGWxXZUf`InXCTFuhP9|4B5ean zfz0+*qA;36Q-@EDa`7#p%e`S0Y5O(>(ZTJj7HHE=UX;LU6-DJ&RQoi*3N0tQ`$18? zMCciL40Z4IvE^RNFl(KF;~sWrA|$cx#e*lqt6R%c2feMHB!vC#!wp0ADJ#tZwRQjI zpu)obEh6zt{-uD?xsuGe3=)3ADb+u?e+^8>NrXmZX5Dj#i|AGM5OD=o}wPtT@&_VNZO2?MSfG~LP zsgazN=>1wJ3!@m{NNF{RqS_c)_LzvraDNE4 zs>V>fKN(DSb&L$^^e(00n^%#2e{VeE9i6IYdsrmA?kXmwMQ#fGuV$tD?yeZhp(^o8 zlD#*;+X)L}+~3`W^h~HzAduT5-jGobs*5Y}TQYaq?6?be>fR|Nr?Q8+*-5sfy<)$l z`&fyD^(#`&9eza>^Fl!FCRKu}UYw#oye50ZRtYU^bH<}p8>t*CCbC2cxOc`9BI`>A z5A9jC(u}WcR^Jd=gYSM2c&3;Q_y%?l`f5txww4X{%Gy@I1m@m{1+a6+UNAAqhp^1O zQiDv;vo#R#+;mdg4!n^0*<~AGQIw<%_U_#B){?HCRFM3vtkEV@NZn6nHRYu^ynmF+ zRx=KQau|=Vtj9Trh`~yW%uj`$*u+q(=gR)>J;yt0E@Y)*XPr73D`lJWr|qeA&p82g z*ui{G{!-EC08WC^r#hyQ;Fso`^X~Q(o7eNI?fdwaGjXC7W2*g{>8OP+U1 z2`r(k5|_QviErWyK@+d167Zy~7xmYqv>~Xt)p_Y<%s62RT?iL|$_Q~=eXbaEf4WZm zaU=QJeYr%r5rS8r@Z!)D9n(hJotadE)eufO zy|1kW6>!^;EgB*0AqE-tnoi?!;#a|u>0zq;@!4ADt%ZgsFf9W27;TpIqFp)nT3*qy z3P+EeG4Cn=bM6!1I~@th`lpVIKfaH3&_}?f9j}VI)3J?a12pR_2DPy@d|;^oi_R;O z(q0OrlfyR+P|O0yv!Ll?Zr+PNaE@5FC8XMsS=7D^+N+BhlNW9s3;|Z|_~~yERasyUwc&mL&Oo{C3nioqeLtfKeNFNJ2cuUMTxz zF9tC&h$l$yTPPz=Bbw`G>3H@!C>=S(OAlcM_Icl&A84_?aI-vi4GaP|=VJ($Idx~U zQgJX*G0e~VPWQpD(~yc?2%YD-8Hn2svML6yQth$mYrl^JFX}tc=jU}|?8E!Vwf7M_ z&DM54sZz>n-1)6QEM|IzNOc!f+gWe|%JA7)DWI7_7kyY}QeD3I_DrWu)#65-%W$Sg zb28aJ;6XgIrsMqG+QSPDLTpvM+vWPK1Gp!}cRQodllfBJTAPf;Q^A<67LDzR-vAXA BIR5|u literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-05_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7925a4f48be8e2b78f01e9da1fc139993c4d5f32 GIT binary patch literal 2474 zcmbW1XH=6}8pq#23PeYw3UNjx6hjoGt2mNDAecx{kOX-tDm9RU4g$l1NEt=38%Pj> zh!8?Z(?O}O=tvJrPY@7SDFadkO9VkOFSF1_J;Xv;gHWu--H5lzZ4I000gvUjZ%v5{^J3;7B9_i9#XO&>PWcb#=7P295O_ zH|gl>Z_?4z!~9@sh}mrXy`J9I9b1jfwwdG24Gb-;EO1t)ICI?EBQO*Sg;qmrqtV(p zj2;H}KZo*nKvNA!1JYDsSU^P+rm6{3HUfr_Y6R@BfaLxaFcnouFG@`vy$(vK+z6<^ zR8>{rst5!e4rQN(?g6+aLd(e78L91c0)>syu}NnZsTuDtf2Ql*J#S(geEL^)wB9Ct z1B|KJHk>)$&fa0y?mdn!u5Lv410?bhA1clFs2>;-8WtWwkBo|qi~l9zZ;46g&SzY> zn0e`PRyK>Bb1OHGQ_L;lmGa93!Uqp4Dyyn%Y9G}%G&VK2v`X80dS&vym;D2S!y}_( ze~eGOo19ws@bS}U#h1mUuWMW|K=q$k(D@&^G$Ae(I9wHuTH}JLBtTnL6OJ&lMrt{G zp-#kTV{OvaboMig%Actl+j`II2A}Rm>zUXMn=Y)O{YCcQf&Kb_k^Kwo-&}*hdQ})? zo~kB50A@Z7z{)BU^vmLk50uE$o02bI6W&o69UQ<~IC|{!lZDsQQtoV+%0AM03#{QE zZsR)^XY`&VobmVdRh3Q$l-_AP2!8QQ=1>KeaUknk<}xXrQ#ogXd^3L!ly(*__Kk@t zPlx?z{7CHEv3Gg-N}z{;k=dk-&WoK32@4V;YDNiU1RjvApA?)i3za%p5-_hTYreIB z<7TK?!)pxxoQ*WLfUo2EL%VZE)9)W2^~uk_Z3tjLFLbKJ`+Mz_4TGHq_U9odP|IqUcsD?d5~6@s|7zZtf*yd^2}zlWV?$PX(EA%tM~lOTs|f zt1oBqffmoA9tv)Tl#I)N5{}Mzb@=&@1hKh^8;r_k7RKeTKEUn8<3ruqEULCv8{y5W z{Zo_FcaZR4aJP2fSe6Es{mT+FZmOj}&$=Q1MspSAN%YrW#JORHbjjzRqm=-<<1wc^ zE&@fmU7>h0`NQ76+cYImvw}Cz{5b8oBhfG;kbJ$hmc|--_AALD_dLiQJNmM?_@!^KK`7hf=W(YPvTu08mobPpeRN_)v z?`Fw2P^pfxERxb#Y&%LxM}mBo=p7<1Fx~LA=c&sdeeqg~3lBv%s%e(oEObfWn5 zj=ZSkroyZ~@f=uPPqq&-?F#w@jYl6D_^A#S&AH!3dtBh{u643RyWF^pCE#aEInHP)v zOd>B94;h?Gp6C$uolF)=&Zyf`mk(%U_uM-jklB%e3J;#rVp`BAj})+7ywjE{Pf;?@ zOSZL57F)?m^6l*2Ww;BQtq=8vjugwsMrzsLimI^p=eqTB?(U->3wbWS<`}RKlx=H_ zk3AQU_|dJbIB?gNXhsmu>ZN_@(CQaSbYMWrgd_~qD8Pu_-s#MaBBQz=WkM!|f}MFH zaQxV()Oxn7>?ZrvP%hRQ0;^ILgVymK5S=Y zB?r)Ec`b9e`>0UbX6-qfy*y-8@58J543&|IFRvNl^~nu>)GLRHxlvy{I%FeTq7U(# z9jMXd04Z&U$rAqcH;!$QFU_~-88(~da=E&Mh^KgOq+LHlb-3^Bc%Y%T>%Ab}-YGDs zceZ(sZ7Ddl{DjRF))f^5I4W=xb}N?{L6bMKsp8{}mAphfBcaG{{Jg-_we`WaPKh+# z0V>td0r%xK6UMHdEu0rmOJj}6aoNSO&Ki)7vQR?A3f?5sKnY+5=%_UpsZoQ^4sEE* z-d)SOa-+F&_QTc{{GQpCLB|Bi?&FQR67W)!!Qs0sgN`BXiBf=|_UzD>b`dAt zi4rjSo@-7HYUD=Zv{&u_Zqm@YvYk~O0%d95!* zeOt~(_P4^Rmy!<^_=@{8Mc~cHnmJoelQKRvv`Z-X6Ax;yEUD#GR0yuOQ6p1k#HN0M z%*dOXg9QTDpCsSyLAVRk5IgCwU6O``9P>!)mCw9X!?~%_oS%}qo}BnYCd*b_$YPzn zY+JDyYe7HuYITC=T>9Hb2KW()=_ncGSd=-U%n?uDe@&2V&(vTZJjv~pY-Vk79|nDd zOU$gRXRqwpJwl+bFj96afo{mbb~K`8)PFlvy1qft{XXAQ&4TSxCk?Ni@^8pbtuKGB zQITTb1^)JdfNpzSz`SSBz`Q*>`y^bX$Wa2d#nZ^Bo`+sz-QHZU{YCnIx9{;_TJm-q ztQKF^mB@CwbJvuCzt&4U8n0{S#wQ(~`ViyU)5Ro-Tw}Z?vmWI-ycy;p$}J7rXY~a` zkk4;SkUhE`YRWJvaB*afl@)QKJKrbu$>l9`jJ#gfB}VYH6NN&zjH7dR2D>pFA|#AF zLd>EsAzY)-Kig={v|Z9_Goa%eD?m z4oI=Di;P{qUMI@utWBmW3sxq6nrMkTN|!n_hXPWT?p@JExBk$ZTk^0&#cHSbB@N^$@@{DrePiwM#Bme*a literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_diff.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7b1b8d2f4736c58fb5090caf036680c96f590a4c GIT binary patch literal 982 zcmex=?Ck7Z99+U&T*4YsVp19;gZ~E@1UVQQ7#f%vl^B==8JPtc{~uwH2fCV-5e$IN z1p`JVW}ti7IXJnvfeN+?FfcJPGc&O;v$C?V09DrlR;@QBE$ z5`HGdRHf`Rrb=&qG zJ9iyAeB|h{<0np@x^(%<)oa&p+`RSh(c>pipFMx^^3}&rpTB(l_Wj4tUm$-mGB88D z1thWhOAzQUCKeWE7Iu)o7@5j}m|2j8Rnd@5$T5&Tu~1masF6d&Y2w0-2RW6EgFc8R z6KQ!#m{`Vr(cu+NC|SQA-3gL@3&uUiZ}%#6UuV-{qvXZW~FW6_#NrW%X778~nz iX{4yLxzz~5nL?R3gk%mS_hQ=sU6w1T| zWwvbj5>u?%s#RDs3}*FOoYm?zHkKHSwS%>dtsQ|#u&{F6}0jccy zi@0-|cp?oxtNt2*38(iMtrWVn@ko|XH`TrN$-(dgd z8U&WWVNmnnXuu2Tt5Sj4d1A&!?6^QSHgT=5TGwB;6ig3nwbl(&{B(+Vcik{}E_a&0 z+{eMzRvH`nUMqSM=Nb2p8w;wNFTJw-wk2morzXhuNl6!JJUtM>Jm!^2!$pmN512)+ zWe+zGSfw>Q)hJ)V#|1M-{wf!IusRtUa&WmMKzHmEULNfjCM0;?0~E6$4j_S%VC>#&WHg+(9xe^_odB)V{Sw1F&)#dLAsweXpe%)X|P zDM{f!D@P++Nu~9^RUYx-{yh!L+y=bcEZLT!tvt_I-S947ogyi0q3PyDT^zVAKIKHC z9mTIv+$K1hJKa>K71dUakeBhzCY+Lc5mCcZ0#(B~v=!>i`Gsjdwrxx8-XzoRQBrB$ ztJ|VFcD}Ck;GK2I%ZyNnvzVb(ZCBrtMnG!wUE}+*xJkK&1y=KOAsKN4MW(5oBVxMrgMwH1z0nKSFWcj}kwX588IX=&um8SpV{K2bQB zGY~N9R(8B8U)sVG9cJA~B@Z`JZacFD2U%p?&ok6LVsrYlbn2cWf=g>rPMkZj1lMAP zqu^4yxQj(qY<+()^v za6Nvon0r|SXE|Q2atuh*1FkzsT8%9VKQ+?M_5$(WaE?f>vxcQgVOu1 z+;X`c*2x&Ow+K&8U7O|^Tw^-_p00rMikwexoHCU6Mq^%`dR(=!zKiq|lWM0@<~ykg z_a_}Z0!5`x2Q)0w0u%frS0#W!MteL-bd71aF<1=Md`fLk#%fr>G@Sw;LJf8=*L|Pi zGcI6Q$eGP{-Lz!tgrGp^HusTzY>xMVzh|YW_Er@4a%)sYfo{ywcWkyGETQk`OiDx% zA(_ixnBp9s0!xtBd3P*nbM?L3Ke*TSs@JYM7rJLOY)1}L+KZOt1&djb{*^&3YTp%)Znu_)hWaW)-B7j(MPeV=@_nce|Y$g=S> zgnsUX{~EL5{efq-BL0|$!@r_o?S!=Aif1!kR=Q?Y8xz9!j* zieXP?vRO*XK>LsPQ^9Kt{ RCu){zyW9t_U9!`^{1<>E<52(r literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-06_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..32e6917a210442b4ed71f1337eaf6ffc29862b61 GIT binary patch literal 2300 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 literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_diff.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b587e5774b15b5032162f459ae140fff6dd01865 GIT binary patch literal 1809 zcmbW0c~BEq9LHbC1<_HWAskv)L1YtUDwxJf)l_Lf1jHZ#1gaI4%WJgYfx=o_M1zP( zgQB&O5IGV7HBkhMsiH-Zf)S8I5K$2YE0x6v`X_w3yl`~43I zhZBz+6`f4`QJjA2bjBHJmh3`yPHvw3ic)p;TG935lG|l>?*3MO?|#Ljn%cU@^$m?p zZO>o4Y=719`b}qVU;q1o!J*+1?HCsU5RF*)_>Ww4oXdnnB9f?MTm+Ln_)Dad$g`X% zQTE2$q)kz;j)?(WOuiD$H72kM#CnAhAzYar~v9fN%R?H`G_tgI?V(4@4gL zlxgcqWUkHZ(jjdsIuIxyYVCB_)jBy=a0Gmh^TSBJgYLGf%NZKDkM`udL>whE1YRek z%EgVmo2}<&Y?ZX`s1jGSbGpX!`L%L=x{Epj9P8Fugs-mLpPJ_Oq9KzXIU>NoPK3QU z7X#PTV&(>8FL95XWsRYyBl+XvnfJt0G00G$Q!s)q*w*gh;w+HMz26CYLQYB^@FQ`ZuAv z_~@+=J%=B*?+Oy9b}@=sTEZ2uG<8D|SJL}VlG1;%Q=`H_O7tjlj^lOBUMgap@Ka+T z9Z7jF7jMS^x)$XV4IX`FM2c6WqDWy*Bvh+Trh>r;sDXd$>sNs8B7!blgD?*T(-MR>Rp#QhA&b%|KPgF=Y4VlByf{`6Q0=#NLC|+HkGrr9Nxu z#y^FxOrWTP5z55g18%dW}dO`4bk5yko zYIu*6F%UK4q=dM!56uGe_`y0z&(Xt%krk+r{=(}@)3*i1taSsJ-gBl8qZ;pAgZ6l$XTws+0=18gxlbFB+|JZb%ow*~@UBjCKlf zq>=Dp46Mxa{hfKs=x9`F;AOm#kd{nhLn)sjv_5uL?}>quEzYV}A3T;lI3nOPMD{w4 zx>?VM!cOTVx%oD%hT~!3ac9=QiahWZc%4qa z0t2y0^%J~#_VdDj~qmy+B0|h=q36YxXTgS^wQF`jDYyFcM zfgzQ!RJ1AKV+X3Xe7M6If@YBjmaW}hh6cq$kMrw1Cyzu9BSA%eNA@QK+{BN9_f%mq nUd&M*aH~FXyCA;6NAMKl$Npt zB%%~#$wJ^Dd#bf4ifmFLAp(NKlF)*Xk_?R)V&;YR%zT*-GxNOX-Y@r_d!FCDzxS_w zqy0Nz8sO{i3qT+M00BQhI|UdAQ4{>A2><|uXvYC>Ku;H@2h-KlgX!t(>lwgJ;c!Dk zxW#8C#-^4QTeetQAdyzqXq449+wDl?j(t09_h9UBc3V;QU)W>6Kx6H&>xV$}_4VNf zaC11^9BYNN!v4>t{TVn_pHa|FQCi>ci^CbuI`1{SynE|05Ry(*k4`k-b>JHVizE3Jjh%+Jj)dPg$0WUmBX>JZp_kXuHpu=vu&n=!>|bF2 z<{ATxp%C!#pa{SV7%lOPItx+vj^t;ilXRGhCtuatf5t%W@e~)BGK4>jD)&Y`&`g{W zG8Oe@#Wf;Yr-a&`qnNL|sbVGJH4~&FrlKg__3g|`!D7cMP9S;5FMc-5QyD2m&(&RS z%MtnX&T(=BWFYPYrjIPQm6cKD)_4cg)YqG8&c)|&>4dv!tnibaxn(sWpxTI})C-L93dB;PGswewd$pH4)m{C;FEak(~B*LSK^GD=l+ z#LcmA(~SawZNyApokR;nhPyDAIIbEg?tJn|JZ5q9qL)~_HCT=tLrsh@=2Wbdk($P5 zF4<0tcV{`6=${JoR&kHy%AwrLGw^o5mBJv=w=J6{9Rr&)i%V11L>zS&xdNT-Gx8Yc z-cK02k8>Xm+|T%};i$0D$a^3#>76~P<&dZ(P$533?)>hl3pTGMfn6W;j7p_E84Sl{ z7StrFzGK8Aj}=|Gtrpf?Rk5mZpO!dSjg(}Q`)dVd<@DE3RjPl9ip9v}^KRNukK4@Q zw|ht2i1g7+oPAaPNRXigK!9$!9T9wo#=HdxoWUYUnU_S@nE}-)PdrFy`LfbuW)TpE5N+Afa+3s#L;Z zb#MDkRcb((MtU&*B|~l+cfzEA|F&U}Fz(e8@=6PM94E?L=j`hIV@5qGf{Q48y!a!j%%`~E@qKH})yrFAJ65Ni z#Y|dLQaPI3oXHlvdPY4i@Av(@N@5oYnjH#WI2~*!$U)sZglux>U~xlEZOo;nx1SWv zfM$X~)ku%K72f2+vZ>d#KzO9Gd@_uOSz7P}b-73m@VZ|h+I8-gHEMLIQ)@~S6 z#1(A4Z*%Qlq}9)PpK}Wi^?vWQG=EZIXP zWR2)IUyhRMue=MJN+{q)q3)M?+SZQtOdC13k00+iG<3qHFJ!{YX-JjRHC44xxmY@V zFZ~w(EZTEEpahjM_dJ9xFgq6F81x8A_l{c=mCzNxNFLF%uXTWtg7S!LSF^(2uC3tv zz~AW9a_fOycFsONXo8Wtcl0l1Sgvv1Sugg}x;JYkLU*jP>WLuG0%%Io+o9r}Ztq(x zKMMJnkG+q#fJyj0n1rWly;_f%ooGX$Ny4w$4dZ#BG5ZIImU5$ob1}Z-4SnQ#k*rxz z2r8a4K-!2xe~FLtmFeOIygiQ)C2&`F&FsaHjhhyT6DGwEnzoB?=PN`#>TXX*#JR58 Z_$T;a&=lmKQHU*iJaZw)fr8PF{u86a{g40v literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-07_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..bca730e5196e3dd40b99e385995525fd48378526 GIT binary patch literal 2310 zcmbW1dpy&98^?dM*;of>*(#?wyPY@U)&-MC#|GBQ$_w{+duJ3hik8ghhq>tmA zZ~zDd03cBS+f%@9*N}6LA?E-9aNB+l90i~dai};13KfUKU{DFTG#oA|376k1wOe|h zypqyBc|}F!K}{9pe$)X)Mb)2FQCix%db$V|17ianV@(}hot;NOFc=Ij0hfcr<#dpW zNS*&#+fM-*3E(Pl6%0}b#AHBV8PN7~Kt<$Q9P~pXyFUaH1B?8^BqZUxL)lqu~oZ~U4<0n1w1TSwNVo-2MXjpgz>Efl>xL+>ECtSagmY$J$ zGb_KK@J>;2Nh!Iq>R$Eznp#?2Q!|s*(%RPE(bfI@MNe;E|I1 zvvYsUFMQ?k*97Yu--O?HxIh5-pID;%KXS>4xWph3Fa);41rqy3bigtYaWxaDtgSmN z;DVgGX{v<0U0y}wGf9-0$EreL%v-pk26{^K>kirvvi}b3+W$rNFR*`eae>`nkf?cJ z8Ndc`cH7O0`|4QWy?!!zow;)FHAWb6jD=24MULGK!Bq`1ES_ep(laMFI2CAW8$-`w zLWxc1Xn7EnlcMdq`~K7~;$N_t_BA=NN$tuS`FOqS_tPj@59@`|R-oIDJ+!&-j#ajS zp^&09BGVc!H&yd;%^5@;-XfNF`Xi^)acjb}V8-69o}NWc2Xh#hbnd_ib&kdqCKHF< zqDzaEJMoCPt#ne9Nh-ZeD5Npl7c^Zq&HS+18+6Xdc|x6c?W9u*mQeAq*vMUpC00cg zWSOf9SqtCTCLh$3 zx#ZJ_b{zUM(p@?;1&+HBzYXMsXJ+IPN9r4M;F7lGGgpNiR=8Aw4<82+UTAuexa=?98L@&kH#h7OUpII&!a@zca3JUY2oN&+L zreLV;Tr#HSw|IqXN(?s_PXMe-gopkerKu0Y zd8cMmK906fZB`b`e$#L!CL~e~vzTM*%Ch&^A!YNa39Onv^HI%3iXhP?sHK_m^O3PV zmX=K}?mZ-Z*nN_ZR_U2N7?p`qcFa1x*u7Y+k{v3XCzl+mD}rP&P>}i3u8a6rEu-7O zqj@jGPg@+tdkE6cO+i~P!g_PDtb-W^UIZ7W1H!>0)I
    nyo)RJ;?Blu_og+hL3jhFsQOe z*Eb&NEf*fTeQPP6MH*7}kI;Q=AC&Rg6^CC+IeeNe_qnch3Cww9YTI(Nb*VS49FNng zP(d$akw!BJ8nYML990*}41wW)B^7N02JTJQ=jMH_^2AA$A`AA7$!0a3%|J@|lSd5w z)~$iyaT-68IBWBPgLiU4bd^)UissnZ@RxJ_br&2q7v7TPX7D4VKL#m?M6ENzdnbHN z{&pp+FZ=}c+h`@u9us?ZDJ;E46Y3E^MDZmw{WaSzlokqDp*8)r^kO|smmMKQf9y3Y z`fYA8-mqYUl{|?0JTg!Xu|ABjKxw;Qe0-lhqZWbDas8f@tsNE4T@OX;^m_(-@ec@S zYwbe}{78`M2sscLWfW>{t-0)z=vJoh=IvBe{rF^7bU76hobQH7v$T2^lxyCi-CN+d zTs~gJs-bS6>uve!w7`9hs8m}rE9A(TKlQ0C)QySAh*?pehz96LT*oD!{I|JSrhQO9 zovr<)-Z5px23}YEW-tfe_xfg`GV2<1GATa9kMwvd zW!S!0x(y@}xkWR6K@dIiNo_AfXq^>FKx%umtBop{3$wR_V%XJ2y5gU}O} zN6;Nre0OiBf$)Z<*Ae*R^*>FfX1SQ{U)X~?rDtHC^7l08{OuyO#K4?7syQ<{M!k|N z^^xSjOW{CZ`8uiPAAN?WwW3?&*5pDo;ap*QU15HO!SUox$Au7`E4K-|q< zIVq}F$v9bo62a{$NswfRnZ#ra)vxOEBEoLOb1HVLw9k84-MWK#(;a~-?S!}x6XLP{ zM-yRRNP_Iso%J;Ir|ebc*>`qpJ0sIiY)GK~_;$5re(7yQXG)4%WBxXPgN5B=E4CvhsU z{(e;}C_TA5Gqx$JQeNnO9Jg=8`wMMtPL1^0b*-ji$IHq+Kzz^C-D%!EGM4cMUpl!iY?@w3=t_ zu*i|_#Bf`_;;XM$x_f@h@?v}YtoQTh1#H>6EigDFG%P$~cjUpCLx+$2cr^Cslcy4s zPX8i2n|?0i{I3@-W{R_Oa`W;f1%;)u8#l|!Z&g&@tEsKK|KMSLLyJP$`t;fJw)U=9 z-LHGz^uB$k8hJlDHvU09F*!BE1p%BM3(J4YWrlGX;PE&-X@(0jID*|cGdy7-lQ?%B zhqNo&+}0(*km8YcrR1)Wohx_PBIrOfnQHIW_ znE@D#s_5d%6c1T33c9;cVA2z~{PGbL&`c%-jPF9+I4m3I=YmJRHB+q(`MXfy44d?A zVVofZRHJ|kcSx59PHVkU5SwV5O5t#s!zd{BRiBCs;KP0C+OEKJPnL;=PN(Tt zN*b4DY#LSnv`U`7vZ=$Br)h($J1@_N3V7G#N|CxSU#C=1WOUZU-XYO7@lA3vrt#bD@^)%`6Lt#RxVbKR~?eizn`fbDoS{ZT?xe=$!{LqG`Diny4G;qd- z509t9r?J&m`=-AI1qa71 zf}G}B-SggGBdObVwNE}VA*h+;T&E%mOEn5S+3i6H3f}H<7k){o5{;!+`!<$ei%kBw z)j3IXLjxD_u_@5YbJymPoaya3!K)(&J`SDu4m`jV2*%vv@|F%Ei86`!WyGqHN*8J9 zg`LK{9kLDD$4bA2DBx!ek(IMTvp$zVEQ+6Lz@bjfF}O=3_4;e{eNqyI^c+H!$>i<{ zL12Y{KirewrH$<20)2;C!#*CQT8>!A@LEF~uSbT9);~(p9uVAK%*`ZHI4mX&ZTl0> Cq&02; literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_nrm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3bf9e3b5786840241117b5732f135ea5774190a6 GIT binary patch literal 2141 zcmbW1YgE$N7Qp`qijO9r;3La2Nkq}m%*>LjUA176P{3E(RYyxE^_rqqjy7eJR_bKp z+bA^<)EqM(iH+05*i|FP6dg5kLLtM#&})nuF@Kx2?)`E<%-ns}IbY6RXYJoUzqK{L zXnqBZ2>yrs0SE*DAm9QtFM$n#amfL3$p8Qx(M$mc03DdNjy6n3M_UID*U{BC(%0A1 z(>L2_xWUNW%)-Ll42iVbjIy!XwA~ts-0HM-`wn{tjDw{Ob|)6S6NPp_uRj8T!{Pe6 z`lkB&rf4gq75XoS<_Ex77dQu;gF zItG141WC@xS^9o{fx( z#>Rh{n0zUjnU%_3&ApZ@7nlCM{9g6j%IZ271c3g51)l$t%NXR+g2A9L_&OIvD+An6 zW0>|foQ{bP86KTtYU}#7u9@$(TdfcEwz~zrL&TgK)<@dydWll5qkSU#b71HHFS7rD z{g-PJ*Z_rqp9eJt@W3d|J2Kd1ZJ^s!#qb$dF;|abxh2ttO>TkaYB8ne`!j8TT1+j^ zTE>aXoexTgM{J6@Vzs{E}1=65u6H(?B#l4{3D#kcNXmvOHi z!(Aip0N9D*I0<&8To4Vy1mjyKvg)t<%k5A&@@J7T5v-PWVi(>nz* zCIVY8Dm$tLK^LCr2}H|OLZwC5T4=?4UIt~{bBfeKs$`|Ju)$?LUlz0=CBf}aS08w+ zc5_em?vEo;%DiR?rK_7@P-o>RKkWEMRh*wDiE zeIpBogVeW%?(Wm#TP>M$f?-uHtrNdLyykmC9eldc!*YU(TCj@!C%4b;2!6rw5KG0l zVqoKX&$6hDP|pbP_JGq<%pn?cf2C!H0>px&%4{X zfQh~TMBX1mOn<#dC3bzJ_tO0bS{MZ)Tyl9b0+oLN`-kQ&Q;?3v7lrgXm(17A`AOZj zpG5b&z4wjsaBt?&=Afvt?M~yBt!XT*Pkc7L{gnS?#j)~7mQmyq0!{g0dH<6f6sp5R zRCR5w8|4BbNu-0OwxH)xpyxgoYH7rEbtFZ~+to))D)Adq%)17!4U8D9@t!jAr~SMl ztD;A@Y%8q=o9@X{G1GUc#YbvL6;y+x972_#5_va^`Ptxy(Tu}{LHmrRXHxDnN_1$`Uq4>nE98a-UI+Ji)a z@*A_8@6DFsZbbW951WWSp1JP@`q>;X6vDRQb1PL>aV0YdllsK9fldc-Y~<&h4ndK` z!~2Ja^Jd?ZozPpTWX6`butRtTWkLg-Cc_j%n9B{VPQB}z-!XmFY*D3R_|AS@tnqq2 z$CfGW-p?;m&t6*+l^ga`6jiTy1uK^u^?H1r!_=~J>HF}@ry)a zk;Th<*c1ErD!XpCc!pkbr`aLjzMMtIzceCu*Nt_6{gI+w*HyxA<`B(cB=StR4=oqbFc zJd_v>9(~)O$QOop6oLIWtpo>LXyvzzpTlt6q$0CHcdqj#gp0(pwcVB{C>&`frhA7I z-DR9^Qt9<cq(K#k?R@VRdBY9>J4bmg_wWmNQqt>S=u1 S%jb|A>-MF+d{djFnfM)}WAh6D literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-08_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..61109bac5275dcc2f36f18146a5cdc0e19aa3b4a GIT binary patch literal 2340 zcmbW1dpOg5AIE<)j3GpF=zuxQl)BASZdp=_Vz%~MSrTG0hsxnDLUk|EbUUp%O(7d= zOpb}}Zj~J7c$UZIY-vs~w+$4j510XLeCod-}FE1w#gUKtv)!=YNMY!e$ zRb{o!n%df%HMO*Kw;FHP)kAF2($e3dk1#PcLz(Gp-?@7ya$`RfkAk0o$dipa)sJhQ)YiRhc-7d{+#+Zd_K3v2eXsim zMn=cpk55egH8uTpcJAB!_l3o!f2?ys0O&ulr00L+QkQbc$jU-xVe4EFnak1*RhN}B z_(^`FJs#$LNyE_chJxn7)co=niU>5}%O;=DZn&0_)rj%eb+jL3{~g$`{}!8t+|(AKHJ(I(mdo=$}}N8pOXN(gK-erm(swl zRS7V5`tjV_7~kL1qNPtM!Bx4dE+JAJ`^MmXZ^WzT9b{4kR0WlRKiD94Ioe#G5Lq3D zv#;P$RhI`U&zTCytuqR3s-T(W&+qn#J9a3OJoEQ-k*aDJIeY6VBt5lf1r}7K3L1Au z1An&5wZ~+la-5pQK?%gZ*=&TLfBzxdR-uS3XKp8u0FftM*V?-Vb_J229YFA`4@8Q? z+!zaBR)B>`aUqLl(C1t$^Kd=9@M1Kb`eJ#FSzug0gM~++%pW`)sSoe;NHbjt3?1%D zT_lC4#I9B6C#r#@v;d7;1?SNyid{sIdzyDiZNch%@TunI?9NILZl9v#RA%41C8m!6 zmw|hhXbBE#qqlD4-c{pz&-8aqd&NHt7ak~%;?#EtGjKF|x5zb%sl1n)6Pb99QqmRT%(A}@1kHSoM(Ir(J;-=&aV0M;FE&)9ZtjEqk) zC<)lIH2>gEG+oFJcO+7erQWsJqF796!jGTCgui!L?7f%6J`9rXjoyCQmsY!fh}Oa1 zy8lH|ZE>@m?Z!UqxLRFhbr<-iEg%L3#zk@l!2-dSZw*!y4 zI8+qW+fRsxZ2=}|dd2W!Ih|p4Hh-8@^?I@J?70{Z;n&BNIk}aX24vlxBAx^IQGeeQ zc_2lW7!{=~1a%8Fry9kSUy2k5iB*d&X*Kgln+Ced4;b;i3E$emy}YrRKbIm1jo27+ zMxMenGs$+^E#pa>v)vFoZS-1G^~#F_bJ z&G%B`q_i43lxxV0qPF~Qo`EoY!a|dnRk^h#W|fnqXL%;Noh1aiRfCyT`zLz|aFrC~ zw}iT%7vE8AjnB0xbqYnG)N;}0(;D8>y&0DpDbvmlqB<{quvM|gRJD_bKH~Odp(voz zw>LJ=#fihn?&$Gs@1-$#9a~+GbNt=0pHaPeiq1to@i9oFJYyZ3#-~fz%kJq6AL^eX zgdOO6+F-8O*=aA@%#m8lEmO6tV$&A3#}Sea`19ShBe%ze5gBzqPj%V}azrU>65uQU z3gQ(e*Ia5WxM;wqJ{DRwi0Cx5iTz|OrrMV#C@~wCiRv%UjKp9VfpOr zpBV4;dw=K{If@!PQ^>DXdP#_Gr{P1%>>fd*^%^#tA5iW`H4+w{fvbopu{;<1qVkRa zY6^H&DV92h`PiNxAKRZw-KbW*fn_#_n&80NK&!>%EKuv7rMDArf-_Xq%CinRULE=g z8v*W+jw*kpQ1k`gVQ&L{+-Y7z%ZhnM(uv76O!?&6C|xvIt{v?_%p7j$;%An5l@dK- z*J`|SrMP~cEGONO^362V8SWhKQ(ZEuqSUWlTo7zJ6VSPf36DEtM0tDNxOm`_p7}1f zYj4EGI=h+d(INFD-KPZKns(_d-+9Yi)s3Y}`t~%*M%2mr?l)47{hgA|q2Z_AQFzlD z!l}wIe2WW6|^QVe5lwlq7oy=|aAB;ZX7f=Ak6I~}tU-&!)b SFCm+kqenCSpmGu`>HizVQcK_f literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_diff.jpg new file mode 100644 index 0000000000000000000000000000000000000000..843e210f9f90b4089c23ed68bff8eedef4d89817 GIT binary patch literal 982 zcmex=?Ck7Z99+U&T*4YsVp19;gZ~E@1UVQQ7#f%vl^B==8JPtc{~uwH2fCV-5e$IN z1p`JVW}ti7IXJnvfeN+?FfcJPGc&O;v$C?V09DrlR;@QBE$ z5`HGdRHf`Rrb=&qG zJ9iyAeB|h{<0np@x^(%<)oa&p+`RSh(c>pipFMx^^3}&rpTB(l_Wj4tUm$-mGB88D z1thWhOAzQUCKeWE7Iu)o7@5j}m|2j8Rnd@5$T5&Tu~1masF6d&Y2w0-2RW6EgFc8R z6KQ!#m{`Vr(cu+NC|SQA-3gL@3&uUiZ}%#6UuV-{qvXZU2Lv1qqnaafneT27-C ji@MG%#>3>&SkypJ5m>>3MO_-qcuYbvWjGqT`2S4+x|eG; literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_nrm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..838d8365b014c1bf2d996334f3f8a88f262736e8 GIT binary patch literal 2156 zcmbW1dstFw9>))Ypwv^E*U%y{N5#}?G}9V+tWcwp1P&OfJ>Dv3G}jAx$+2e49MH)$ zFRAUgVTj;uoe?R#t>q;xDpSx@EUzV|j5-Z5WbCosXZN4|V|Rb&dEbA|?|Gl^=bX>) z)s5(014bm@qrLzP1^_VV0d$kV#(=0ezo<9>08Z-0fWrV1p^wx@Ad&h=6bfm8HbSEf z4bi5XHg7btFtxO_Ff}*FY_r2+tZc2#&3EkDVf%%>Bi?Z<)_J$H!)`kVM~C%8U?>y{ zZGbjGqfHzz<`{?nxpaR6j17P*z!f+Q2k05Y;Kndr7l4IQ>%%@56!&w%^x#lllz}07 z1Jocg0`y>TxE=zoua7`L-QPm*0fe#s7MndjNRz-Y6fWL$?|1ok3~UcIcA8NJe%<+H zc*6IFXmbn8tr)v6>>V8OZtnX$_Ir9AK0@^M^FK(exa3x88MpE-uZmwS5dQp2lc-sI@4n>0!>;b0-ba1?F9wHX z^5Kz}qmxt9GqZDV-p(tRS5|*}|GWBw=8ttQ7y$nV7PS9IE@OyG4}pLqQ0rVUy<}*? zjS>1bdyrdv0#RY{Cb+%d8JHf*zth-hX!|AQSF`Yh0kru}w@JI@b+pf9{~g%({})xmGGYw>jPO~noGn?R0K8N89A9*h=D{zk z7C%jz3RaxYx-nNJu?8>go4ebR;V4pBPEQh|jbnPH^a~|3LV7~FEMfK&iEaNRB9~>t zrXZ}8(gzt!HDlsu^!m+9^L_1+NmJ)M3`+AGRNweyFDW_L3E6O*LKdJVPTP9;+PP!f z>fGS{wO4dNTiyPdH5p#;HZeRRFl>OLmo5<;!0v4d&r`E}Ckd$xn)KDwAFS?Ub+BrFJ zFlc4LdtrN2$UhZ2H5j_wjd7kjc9B0*?NUC+AFC)>Hwd)UKI{s!>aso5cg^v_BVjYS z+j*$yV7o?|^PrDGEI%H2DVJtWri6N!)~QO^JUhzHeXr80Bm_HpGr|4X#SBta+cbY` zcET*KIS$GkHY+E#KpFdWKnwTox6{@SQ7-;$lg)b@+7AkQJ}Y!tK^wYW-D{Rz7=5HV zeoppG6Lm`4afy+r6(*kE=I-c`?E|bSsQ1Y~H96}5ne6lxj~AKhTyy8zlg;xh;IS@E z4gIBWzaS-(wVyoU#m{ZGF?!ixI@TyW|yD4`^Z#jl)OqZPqkOw z9vMFjhSNpl&${RA_5jOyR*t05%0p-wwR{GnNy&3f-DdfO;f9NR zKuBdqW+(-|giB1+8t9cri@08=y;b@;s0;hNh1W97-3mY#XmRp|CaFpvpBx;5}&6Z=X>vc+Y%%@Aj%SQW+3bAmx4( zZF{5Gh16>R7Urs=yWq$rIbXsp-5+fvYl22^2o&RcoE$88yq@^+{p` z-`%pXk~{(S*{;cG?2c6Crpqi+tOCPmAVis1_i9I51zJh{Wx1^LQ+<8x+a!98XV~)* z@)BgdN?y1@E8!(neC0q$d0}SrK40GTPBhSM=|1-2 zRD)?rM48(g&*l7V@sZ{eMm=H4e#Y<)zH znOAJrQ*qn`I3KKGQ4kvC_&hX!W2H!Mw{aXTO9!MfkH3=C-t$vA`9rtjkZ3@fdy>Yb cK)0oYAOm-IwABF>5}rpRk96Y?2|@Sr@9z5hVE_OC literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-09_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..21e0a77af806017b84c142413e254ef02b71f63e GIT binary patch literal 2377 zcmbW1c~sKd7RP^xBhPL*8{G>zA=uy)Ib746FfcI@P{`_Lq6uZGkm1uz%`~U3sTrD? zsDJ}@ak!?{tt_03-uvhM@!s2KowLqbXRouqpSAaQ%ZKD+ zz$P39ivd6&001c#ARh-b@w8K3v{L{89FV^N+yPZ(6;%~wRaF&LH8oXr$R-FxLj$7w z!$!?b2D*lZ2Dg#XazSRU_j_YFhvv+WG_wdAc?Ze{yi6nABAccA?gccfhJe(fQWSxmQ%Z^RH zka96K?Na)c+`Rm&1=l!*+_Lft9$!#dRaf88_YG zwW_wOpW4w#9b=nBbzQfdQqe;V6I;SZz2K7;4=04(x!m|gs-)w%rGr|?mK z%YBPgZ%bh$hQl8owA4J7^@p;@`DGRSPGX~~67CLfpOSy^sP$307<)E3n*Pq)7OsV7t9`;&1&Ytyv75$ps6)< zlWAGU*KH_MSbUgg1E+!_mSX(($E`KqVW6x6oaaki=7ldjoPC%9xQ@xz{-C4THAG zX*#ydH&fc<3PSHJ+GJcRX|xeM%Mf%IlWo(h`9`k}j?{ql@U5VhhLj_t9Ji#;-M7yD z&~=L+)+0QG*=|PY@7M0n?B|Ig;Q^MTVVyNIGz5L9+4U`HiwBGqhCe)mW^!lrBaS&M zdeE7&CgnpU@jJfek6Qx+}a_oU$l4&XUA zpiu@NRD+MxRG= zz)ESOJ{P;XMY&6j(f+*!6f%2}f%99^Y_6E?xzjAtJ5Ya}EomxGtG0RGQ8LfR7mq!j z#s)YpBVe91)0@3t`Z|pkTp&-=-KPeavg18f#N4X1f4IC_L*F}Ia=V7JPiGBXPk+?O zKh&hlNXcVUIMMo{LT_ZWUf4_f#p>XPiRty#2yWKt+8-ui6{l9auC~5mLGcwMzNRr{Gt9?b^#B$|Ih9*IVw<8`}wZ++7 zZVXw;bjYr+Q5c7P(C)n^3*Qc-c_1W^pzPmXtR{>yYZzy(_pt27*e)r-d)11meu8C6 zo`e+DEjl;#v1aJlH#snIFnY|LqGhGCwo8U>5EJ}JIpmmi!(Ck{LDbh8tzWI(;!0W@ z1cMe1?+8~iY+uJW0;J z#;4H;sJ(+5@c8}7q=Z5azqt}^-%0mz)=3Y)73yhW^EorsKQGZw7G3-aQLP~+_m>&X z1eVJ<{d_Y@VZmS4>gAv;CGV?NPCK9U2yWh7Zen>N;_#Ez!j42a5Kj%j?MD}#+`L$Y zGUav^qa4Mn1>f4o0~sg-cox&Zs>Jv=r(DHsWJ2q0gt)`QTXZl^a-gk&dx5QYWid2% zFzLWAwXt_QP3lsJW8FUwr4|RgFN2W?_adiAPp~d5D*kI)`$p2XybnZnenmxMov2YG z((FjT`7%n9!E8@?yff5SAn5VtwW(LxEy3-m^%cGC)Gj|dhX-q68P1Zm)`z`OOD#Pr z$wHX?ReIoly|G@v@5eqFKEUz$f<^iy-@KDDVDp;VR~S)bOS)1U5tOL#mnV#cjd7W) y$|`REa37lHx=??l#_|NyzfDu&F7?zBY8~F`a%&yIPXSk~+eahU6cZEV!~X<#S4T1c literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_diff.jpg new file mode 100644 index 0000000000000000000000000000000000000000..53fb18958d5b0e0857c9b3a3a01bb3c1a7679f14 GIT binary patch literal 1554 zcmbV~Yfuwc6oBt0o2OPlk`QQ{sE91789=GpDn_8;p;bssfW|5sYgM#FK~Qld(Sq7I zmikugSX>^GASyxtwKlPew$Av75)^!Z3L>Ep5CR%WwwuLi|MW-Ovvcm;xqDvUIR|@y zwF1w%(Q(lLf&hT<4`5Hh=mjfOF)LI6fCTIzhyqlYLZ!e|Duqg;QRz%iCX>Nnvc`Cf z_Vi(Kxjrlo$M3@czTf-)A8f|-C99cX}t3{3zeFNo{~VOIekubKi8f|pAOL?Yw$(&!B4DBQr{2}lr`OoGW2 z3Jl}cTk-z@_M(g(9~wdRmMo)9SkIcWZSM)Xe`Mulw$$7^ajG(XJA=vb;rjXoObSGT z1YsXfn?B={Poq8)N5{m*&0Q#yCnhC-p;-Raimz9$TAiA)Av0^^rp?*k=H~6#x$8Ua zzWoOd9y)yFXaQPOTvB?nOs79rRc)xLtvi3=*DF`+uU-G`_ZxTbHCP(&KWJ)x`s{gI z`-_g3oz}ir{R8&Fq1SI5ZY~IrN3ihoAGy46E)oorVVau@B5lN#>;+TChf>EzNNCH} zdrz3Mjn0bPd!q6(!+)x@m#s`UGdUB(o(A-}(Fn4C2e$oxk^KesH&-(lO@{E{k-dNj z^l1_F5@6~?j>hs#soGu4M&|LtpV}T)wckF;9z*ypF)c1Tt+wP>Bt2<)qPkV0 zIh@Yz;o5Hc)OnhdvVw<_UKJ@Zkf|0-rp$O(!E$}Bl0~AG$O(@u0eA1=?J)W^FFW!bq4h37k@%>roQ+nBea3jQh#thA#q8P;M9 zbSa97M_$UY{tn;ns0KQsbKQ2`?~G}}K(kY@)+8iG>tW}2rwt>7tu%|2O^o(lj@fm) zE7~S%zxF8Oc=a@xCBs8wV9h|$9~kJ)IXG=;6yH(r&|XZ~JahIpP1k4Ck0dXxCS?me zs?}8v^I-mRLQQ2y;E-|dtTxwBryN0B{*9hwyyYH z++!KNq1sBxml!DLD-zF+>EHjhrG%HR-ZG#+AXz62+jT?6i#3%I=JWHNN~d^n(K^BA zU}sBXK2eb6LeK+}7Ab|~ff!I&(%iGxh4|v=+rrQE<*>se)(ofQ#mj2I$S%)&my2gg WbT4D_yTvUT>8VVEpxUKstm#k9pth?3 literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_nrm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7165a2a835f9952a55b1be8e8291fbc2c8e2cf83 GIT binary patch literal 2185 zcmbW1d05if8pnT#ilVo~Ek|pt+;FR0GHpu6UCAXuQmN<((-d5;rn#h9W|`%3wNS&# zQ8N+3eH)jUa@W*H6E(YLYDl3%$S6aMdVh7df9@YM_dU-!=a2I~=lOol`+d*$^!9r| z1?}qL3P2zL009TEJr5{*2S>RDM*#qEa{Ddd1jx(E$;-*g%gf2bVDbuZ6*yc`5w5;t zr?Sc}buF!3>YAF`dkl27b@X;?YVI}Lt7m9rY-0SKuK6KzN|#j_qFngaU94xCVvn2V@WsC<3zG4d{ZZ03pGEh)2OhFN@1ST}8 z05T9LR7MsmCnqZlW~YGXfGk2zb)SWUyqfQM*#2mB%ji$ z&0SjGX&V?CA&pI}58E8EwL9wM?BeR??t#Yo;ZFGn1mc4(1czJ<4I^EOjk_G5keGBM z{pPKV+nIOr@7<>q6c#<8mX$xLcv?xTqSrSxHZ`}j{@nJmtGlPSuYX{0{PhHTa%%d` z%>2UQ2hI}r<0qbQP4w&fhWIzhm#%YFxT^)_0xe=~bV7P)z(CJYA+O@@Zfx}cEm}Mh zPS9}e@L=jt<3349N_xW{3HEqx@o=5B|1tE&u!vn;C^30dE8$)F=`zmaO7CwgW*5qw zB8&v4Xb)6;z@$WE@_V`@)-Q+Fb?C*xi-Z(wC*yl zxGC z)We}OixId^DO=?p*N#Ng7b@fo*jAop9(`S5u!v*0^mdw$u>gpJ_ z&LSD@w-D0J>STJc+Me}~3dkqo`LwDOWs8QAy37+%0kHoAuwEmgLq7L+!6lTI{A!rCt>9jf#i(F>pBGFlF9?5*Bmd<5KT9tyGmx6Ooo_YYh7%o2#K#S9$jZu@Qkj)6TDgH&+P`(zc&gr88|z z5NsuT8S}8Vzsl&#;8=5CVW28YKz5w*nH|H}^Xz!%x$DGE+7Be94n9*UbPLZ&DmiRB z^zOK_OG1aEl4`V1dqq9LII`z2Qm&27Gh9M;{W6A7Gvso6sj~fCLFv$(rs*yXjA2Cm zqxIwCkM1vDkkGHRZlNnt3A|q7!h~mWvV>B)&;IPG>!?Kk;B&KVzofG0b>gS)(Yu=w zVI}Lh5|zzWQji#Tm_Xz9Orf(ppXzop=&UvxdTXPG4svno8ijdZ>JM?TOYFDFlGuTN z`G`G2;2kO0ulh27Ca;-JD4{-WjLK&a9+UQWigIF6GYEqR3;DvT<_A4)NnRL&q}obx z7_Yx^a5$hXy14-~(zCOuA@oVGwY%iPx(mi`@fu>UqK6w&snus%_`G1S7XWV-}}U-ExxJ3l7-A(0ZPCT$y`uoC^xquR^J&Jo(WD`kN-L%sx~!^iV< z!U~-upA>U#mluL}qgBW4O-w)XIFBtnpUd1lnEbIOtFSrKbN<$MDjY7F%;y-ttrVE72)Fq3w}_=vS*>gIkU2}R zWCjXF3|#?f*-c8%{t-0Nbq>xOUtWW_8=3Bj0XrL6D8_kR6OkQ{CC1^tzx~iF~v4|glX`hLs_BkI@on0nabb9g?~0=D-|+R7ew*nuFH#;uG=L{hIe zvLh+2ufm@H^qpk%PKDOg?dI%Z^G}kn<{)Yc@At+=?okVijAQ7_Oc9#_{-I6v$zP2` ziD|K@w$HXh{_V7fbZ{)+XLi!1)jl|MpyU9>ol#z_n~|38u9y0by%9Bg>P(=>)H8S1 zZvtOR+$ZO17*z6(&5oL7+nfV;Cy;E?4d?DKpZCagYopQp$>waxtIAi5=J%2~>s6YWvMU0N-~b9smFU literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-10_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..12a36a5197b3a1ceef0cd81828555c161f0a4d8d GIT binary patch literal 2444 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 literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_nrm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cfa95acd9c7526bfbf50086cc88e5e5fbc08a87f GIT binary patch literal 2210 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 literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-11_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..af53bb56d89cc5faf9f779d97e40b52f9f544d64 GIT binary patch literal 2468 zcmbW2YdDna8pq!;V=!4sjKg$r);N{ZOi_zMG(rqB%#6`O#;_ z($>_`(bm+`LTxkALv1nIs->lGsc&Su!_3@FSI^4Y3TsSpz08r2+#vnD?)w*DECJ|6riA9C1n-F25>|DCO`oK zg(|?Hii$88xH}pA4#3nEHyhX-fNS`LDjCLW+NNJGRW@>|Zb9N-%^2H-oxY%g(9+h? zMVXlHK%1G{W3apTI5@hx9d!3Ng!A+dAP@tCNaXNiw1~*4=$HgLBk|1Hb4eMQ7cX7T zx{{s8%xB#wxLJ6sj9p%FyOP7@)zsG2H{845*wp$&)b_N!wn}@2e}krFepq(&IM681HMpon4*CVeDeW6 zrOofV>Q@3l9m6grE7%D_|HI)O=QrNm@AMC9Xcpe$FEz_DQ-o z|EbH%q=vog_2^i&fP^}wj#86bp-RI+)uMee;OXs)6jSp$_Vl#@L&Fa<0<6nUXVg?h zW^&2Qouoq^gU+bFi38|}2KP)HW0H7@x2fAB2?dzQB@1k;7cLOv>q(?#Ink)-s)$IW^YO0O ziE3nIe{jl*5Xbp+kVJG_nx{`x!aWlR-T#P9|CE9(P<{Mz@s(YPVQt*oB~WCfPhKr1 zzkJe&mv>+xwY72S=$esKh!+j5BQNWN8S!_)-Vekta0YSC@ry(&QGbb4c+ej_C_PcF zWU;T#uqYzvcZNN^L0hkp7g(V8aMEue&p@!!?i7c{2TwK4vPP(4tC=-(s<|binje0S z?X8a}z)lo}jw7)ioc1F<;Y(c1ZiV7<&)xVl3$$TL`(Vxcij8X@L^lv|+E)~AhcSbO8yR)0@4-y37B6T5k6J0sei{VC#fesW{j$kqd-M5zoYPr)=N z?$4MJ1O;!n`jnSnlw(RTQy#&d2ncbtmb5?NZdmSeC^&Wxec;hAyRbu55W74f3NdnG((86PSr3Up#bzc-kTb?Ub4BKg;3?CQT4 z^ziJsyjHiav88uzd{ui(pAOmpnOi<1w4MzXbPJWHua`#{s+m8r64bb zIY+I=aAupC#C%o&y$dyvfp(ZGTq8R|ECUi2iP-Y- zE@3nJ8JI(%y8?|{)>E|IJfq2;vGoL>Ouk^D=i9g>`O1uMCtqn0-u6eSa0u6-jv*Zi;-D+=2c?u_q9YAn~trs7q9K2qV4PPM&7 z?|6duJRVW*gYIcDt?b2@ls~m2IiyVWT{M=|SP?cp@2qwm3-sW+AP5__oH|xk9KdC6 zbzJBaa?I3;gd1*PtI?NPkG}FzeX2*mzTxeFM^54IgxoAKnh^}0J`bvFxML_n7M_qp zcsuMQ26L;9=bl!%w`zfKGaZ%Qx#{lo5-i>e9038g{j*|c0Y@7y; zCc%~$sUN=cpNNAyUm`CDf1&8RVO(ZPu=j+&h%l^VEmK~l{a9O8IrHE<(j^Ji<}s2& z+`&M_@8N!48_3l?TzmuLu)lIb<0o+uuUfOq+`oxG5OR$}$m4Zi^}Tr7&=ct)h$7RhuLOo11upxT$^&GRU>vV1NA6D1WM>_;jG=)*A za6{|)?8muT_g7=dn;wvganFbz!KU&)wDgkOlwklmmS-w2*J!`DO)T29-=M2L7W6rc zdUsCJ_MlzvIW72**E|cA$g~&v_(Z*H2>sNz^N){*$wa~VTW!5p=3>6Ie~38C_59M) zJN&}GZZgG@kn5H!4)+3t5o2Pzh6@=*HuHZ=Abjsvju{g75~vPvNe}X9t!+7bnndx- zn&Q~kt$mJ`q``Pe(F$bF^Y3lmZAANBMr&P>q$h>^5b0%BfFS6cINx|lmv@Sh6PPFi z?oSNf)%vhzRGBn+=hQE&7V4Vv8$i>%*@ob|(E0#I=`+a6x%#wez*l~KB=>?-slL>& zxv=ju!LQ<^-dATt*DdD{>zG?Ck7Z99+U&T*4YsVp19;gZ~E@1UVQQ7#f%vl^B==8JPtc{~uwH2fCV-5e$IN z1p`JVW}ti7IXJnvfeN+?FfcJPGc&O;v$C?V09DrlR;@QBE$ z5`HGdRHf`Rrb=&qG zJ9iyAeB|h{<0np@x^(%<)oa&p+`RSh(c>pipFMx^^3}&rpTB(l_Wj4tUm$-mGB88D z1thWhOAzQUCKeWE7Iu)o7@5j}m|2j8Rnd@5$T5&Tu~1masF6d&Y2w0-2RW6EgFc8R z6KQ!#m{`Vr(cu+NC|SQA-3gL@3&uUiZ}%#6UuV-{qvXZZL^W6>^OUoVYCTO_Bu zii9PZYAouykQOTv7};@UQJ1#k%#uLYH07W!jkRndfvyax7hOeX^b%06u&7Ie8LWkn z@>Y>R2T6ntc$5R3#enW0tjZHzMFQBcx*uIRm&T$7Jl;hpmk4wfVI&kfVCDV)Zvp_M CUrq}E literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_nrm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..73de720e97c7a0a125e7194ce61a91f82fa85907 GIT binary patch literal 2122 zcmbW1YgCfi8pmG*Z{#TnBri3TW-2pSYNk=jRHjLj7vu$HDwnrRope+TO~sm=GP}6) zI$;edCIMP$DJ3-3Xqq}?rIcoPNzv3o#frQj=e1euoG<6YIp^7H@BLw4*8lfB|Mlp) zbpwDY+1u9}fWZI&1`R+r2$=XqCHO=o000o6>jicJ1_*rveT0F5zJZ~kff3Rai8MAw zVwSHkFv+dJTr!1eVJ2R z#z^cca|=t`4R-bp_|2|c+}yVkcX{ph_Sxf04x)lIdTG;WM;}yR%+U@ zli9zXI-PSSH@~2;sJP^}OPq3E#kETQb%C(<_MN(V@!fk3e>OHfZf<$f+V-NeOaAgz zchBp=w?o6qkzoW$?g%stOGH~cpD@DgyjWKA$k@h(GG!K?^c;y@zj@GhdI{|-*?$N2%l}38FR*`e zy#`F+FsOKN6hH!=)y>Pp2NvomZx-Z)_KdnYYKb&4;+8so-A~?g62~nxP_ppp7=0EW zjY}PEe#SYshu1uJZ>?4l?WF^xqNn|mYpy~^>3V@zxz8kiKnKvNzFOsv9jfuldg8Z~ zf~&bzS!oPYuHxlh@g)A^#`y-k&n|p-Oy+*yoPOGe>Dt@21*4++4mI_u=h(TJePK^j z4;6m(tEqIt47IpLS-FVjtuQ8KH;q3?7>IoFE&o%-|STfMf&%F*E=;Sl;WJe;KHfq z$cM8O@WuC0DnJziP&jSKS1dheU_ZO`diMTCQA)L|Z5RaxPe;f?JI zG&w)tCbodeQ=?TLIIBJ2P>o_g&OCrot>~Z;dX+*kj?F)*0)5jfryKdFZ0=@ps$P@K zc1~;)rtf;Z)kooc3YBi)&s^xNW(ePiU2QL2Pt^SIb9Xu=^61GCQBYp7=rxy9wcoAX zFBD}y);7IX9Qu3pQ?5i`{+z=yx&~&IF#}TwGLF@8L?W4?&5i4Y!o|QRHh} zr<|BeS*2P*AG?NfY=1l0;P6?c}=toptGoKJW`&ld_{!Q&a|rh$>q=$oauZm&a!0 zIq2c*l{T3S@qA%LzXNmHdF~!J1t+60_?QW4JinJIOw#Oo_o|?=_Wp|*N=WXJo{=ku zx=7j0=ala&==CJmeNe)k6`o&>!jF%5U}5e_xqo}%(@`I-A^<|TLG~nXDEce0tE_#c zIagDZ(yK|~8fle>H)$1K8XMDcK~smFrYM0V5%y-K(LS?P!hMIC)L|xy^I^X2{<1}t zfXtPOE^cfEw}mXPcHh1gadSrA8$J6iF$~(n)Kr;cP-)L2$lj$R0z#gd*~3|3T-0m~ zDO%EL&jshmw{$>NHG1(zEia)DROMx->;vk3TXVf|8H6(ZPU3XlNz&95Q9mM`JqFfN7F3KLIUZOo) zkjDyYwYU?6X}QmMLvnRZG|CXPb>-kIpj{&b}arsA4 z!B0BC<6zNJ7TxDtJY7t>!gpIh{jNZJAiAkvD!P%yBF9nV4xarSXs-j>$qg%16Wf=> zrfC)XUpC99UR}+xcmOp-v1(#X$0UA%u+$K}BIrhoH_&b!wCax5kOEQ%{YdGs;BscC zdqCXs-7JmZvjT6$n+f`mhk32>JLh3F*xh?uMwAJcHOjE(!FJV!Q`nCox}JXm@qYHO literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-12_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..733ba5dd8f76a35e505bedf6275ab0760e485ae0 GIT binary patch literal 2300 zcmbW1dpy%^AIE<)jHR5HLypT~le0;5^N$vq@Wf>I%YZqBn+);>OlA2BKwQ@N)hW|)C;MM2dwh<@J$U|W} zH8i!1j7>}tNIR7MUWa{-u5Rugo(H|W1Mvi6P;f|SB#9gqeJmz6Ifa^YnMLMwwz8!UA~+B?KaIl zDl&X~Y*$&CaYHVe8s8Y+!o6dux?wl#<#ay7zJ?YoFQ(WZ&m4W7yFVFkj2$PYOx4+U z;Cwsy3G5zj=`xy}lFA2fj|#^)_aN{qZ}=z4Bp1>xBYa!mx6y{PLXP8={XfM(FZDiG z#k};mQ0-3S!>L;NT4y?L4kytUIyIlawHf|xGM;#sna+%Lo9Ed3W%l$dJy0kNK~B3; zt;C$Fo0WnZ3nBUlzMi?-WL33b8=hzxm-2dtbICKJE;Y2oRX9vaI5fmE4YBz^NZ{C$ z;9Xqeag*|X8!c0B6MWkF3TG0wU5#bDleU~r4|kh#|{bBJf-Fl6F2 z*qzDurR*mHZ02KD^;KVssNYih{i($}svzC{4IkgFPkaA42q5rrgNbavN}<63z1iSl z-YON-Zz~#}|3Y-_@nMBbrVuTAeM~bvJwZGxt>9!IIYVI3dxzpYC{@=9x;)XX}!D zVl>4ZR)4m*iFaCDhyF;n=cqim!G|K;?z7nob8Y3Y(A%@U_V~1030!8`z9uTfSIu&o zJ|Uk?VUb^>Wps1m1gB;VL+D;ynK6ITb$Xat5K~ONGDJ$jG`GGIYgFSanm&!wN^!sa_O4WfN%QX}E|fHWP|0oR^Vt7}6%eXf17hi2UdC zwb7yGE2{)Xj^+}@n<$D3zUTMKa(3w`(rjcS!)Lb>k|$;@PpXNK-{<581}D^EWqJZ) z(_Zzk&W+eU^EKO(e#i#nHf&MFfW&U|m_z141ETdQW$e}CJoTSP?z>D8vl=4>bwPoO z$lB3E&ZB+uH4P!eeD1M7FwP~N!Je$c%_@q<{Gn$ozx-6vdlS9be4gF{#q4iOA0j;0 z1rd!m$`GFjgH6hcY7VqVCoq~k33o$W{O%A!gM&o=7h{vcC^6~#Y4Qy6CXFZ zKCcw>h}Iw5QfDjq?eN`#(0=v;1+B3OWQ7dG7{?Q7a}~m3aSrqe1D!K@#L4x>erRWb z;&@;=s#b$*`!%n~#tl_$TjtnYq&PPGQ-?#zZ6+h`g{n&}+@r^Jir~XWlfxS5D+IsU z{po?SEycVHNppf3Q$spFPIXu;eT5qCe^FF(uAnB&`21|escE%&)CG#oMkIE?M063m zLc(>l#|g3y&$@2u&lZ0sXKXl)Rot%C$Fjd2 zAFiBUnRP%+QJGP6Y@B3RWvkP+Y{&Mn_;~Y3P)h7uB}2~6IOkD*!gLR}ZbY#($ZsdZ zrsH*t+l<|`HqvZboAYa#^$0GkFAdKLTjj<12;DNSMA1-wu^ViSn#N`AI(Mf5bz~CZ z#)ly%q?z>U+|-PA>`xAgHswBJsB`f7?mLla0@+1=iBf(aCvjap*xcQ*C?8p+(b|!Q z(=)DzU%Aw-HJ4DS>l?w1?2}9|lwA~RrjzJSV|R1kvHN&WU4$Fzg}Kv`S>3aubIfxT z9-xlKPJ~=nW}xB^Kp1#qa38m}M^GAp=)6~O_;DtutL@xs;lNwT1hm+&76n!Xjg#Kb jcy#f|nj?fe%ul>hho?PNHTvVBm?{xDuS#7yXLIB)Frpml literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_diff.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_diff.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1ae129f45df8446c70c50be4ece92ee66fc893e9 GIT binary patch literal 1739 zcmbW0c~DbV6vl7JN<<(M0%DzrEQScOiY--w(f}e>f+T1JDQm!~GEx>n;R$tXlp<{+u05IqSkY1qmWmuGZSQG%j8+ip>0Re|6;Bf>3oK5j7K}v^43S7AlgN5xvL3^T zX2kfX%5;B(Fd>ROtc@3>K?_ z!{YHc9J)IRT?aS{ewDH9c7h%!h`9bJ)h;FD2FYZ{orn59t>dQl!O^LjWSW8DY9llA z4GasW!)C`VpMK`#`UTs~-D9WcUSBTHZ{L1?NN8BNAR_Y6H!-ns@y8Mpzdd#O%-M6_ z2`^o~BD$J+?Rr*jUcRKDu&B7CyrQz|ZgoxVy+?JApVT)zZEX6rt-a%AXP2zI_m93e z{cqn53=U09PEF6u&dtjg7P&A0tHwg}KXOq}E)5(Giz6;_VKm~=7fZq6jcp05wsVL< zNA=d*rI4sQGH%>?sA*#FGp-*T-AbmJI`o=NETS!u{X4MK|3&rz>?2n<(86NS%fnIt z3rv+Ta;u#lRop^AUoQf5`uUr);}JmDnTMDzPa|@LnpRy<^Pakej;6vO1X!_jhW#wA zq?QIk#q=?5R#}oNANs|`E{)3Z$QVb!tvz$65Apn2!*~b3uH;1M3^H809uX-k+ntP0!?P@#fwi{C`W*dO4O!HJb;Hsr|M|fSL7+c!@xWxhA)Xpmu(dP^% z^P18X=jGls`x^-G7mtzKi}KTGj?5AgE*0-R6{=Ds#}Miz`k87;^#s12!={opy0xjc zwN0pEv-ytViBWO-R8B;ekWpZljR0kZ5|a3qqGvVPGNBUrT)f6TPoHUkyKnO>p-%c- z1THXFXmKI5XDASE+aFuJWg!y*F)stm&8Zx=hIP^<7Ed{#FikF#cVtQ1_?&l8VY&wt*Z*&%{NjyAP2rsG(UZqdAjjO7n zQ$+V0>7~grGF^V!#GA_v&v-*sa|;=&8Q}VN2|vs8nHNueRdYm}8CdQ<)RXOd7LJB~ z-sp3Z9kujL4^8ioI#sZ3yBH+p-{7 zXqAoxnmdNPZQ+Ar_5lSQc7Pe`tS?-yEH0}e0diK3$nSh#lQ$xsZkfq(+{E!!$q?X$ z0KW74F{IW<$jJBLr4=fV7xpQKohlEr`d+i>?_63pqZJwUIRfN@c?5VC^TrU+FYbex z5@VZE8f!RRQFZX6K4l1iW||OT{Uqdu{t`+cMBgSvxN z2pEV^cm?*rD+L@E{}%~;F=4!Bn+Jv^((p`$xTwHYRm=KKzxabr`jRzX8ddutN(7cG zi+IP#Vy~~a?6LUE9vYRa#wVOs~g^1E00a}F+HK~Z&9Ys!W#q}<4LgF JQQ_5y>@RYDE2RJc literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_nrm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..da4f4601b613e6ea74b250c5eff41087a32fdfcb GIT binary patch literal 2126 zcmbW1c~n!`9miit*fNX|cF`J@CE+mSp0pL3NH z%D(_QeqO#_01O5IFsK2_X+Xm+3JeZcLBLg25eR7ZRp>i_K&k5PvvE_?3yN0XpQw+&Uck^WJ=*xpAb4ce>`+Wn zmL}5B$atrTxdjGm3EJA(J2)P8a`*7`^7irdBZPz!!@?sXPsPTaK6CaQ`C>BlQp)Aj zwCo!{=G?rMn^#ywEB?9UUMan@>QQwKlf~xz@}!B^-12KH|9RJo?w*&uef^_j0^#`U zi8pVj#UEy7C3Eu&i)*s=zkb}1|4s2}n+pcO|A_^?|05R);!;5%;0X0?E|^LR^uSRF z)qOTKn%Pd9uWh4!A^Y#Zvi>i!e}Vm* z>n)%Khe3x2M**&Y;9B}eO5C2Pz?RJN20tax)-Y02HjL4gFm$t+E8sh8=_1Dg>MKM- zKe_ku6bCPh4A(1>C?&wH6;HKuS)_k$SwWm@Y8WE3jq{+{i5#pXy2ILXLl@a}*DaJN8+x+%D$vEUB}@ zzj-%7S|VZN2zk3Z#*Q7ZaCt^sfz`Y{V|RMPDUNe7q*}Tw`UkhsyCr7g(z6+4`od+_ zvXO-0n%woZqpa5_W9xg`XDQ1gBkhUJmdFwpceK-H{>WEL=c5PPXo|%jk`?D(TdfN=mSn=iSD2pPM6LEvN6UnE z_r&=;1?M}rZu_kfLS35H`B&9XB1xR9fe+wBU|(b8rKPRG%@$LGDmXIW*gku{%(;)ebq}pi5dC*~PEw!jv!ILCrh&o0--XUq ziNP!V{wB3THMca{K!}9dZaY92vYoOV$>uuvxl>B=etMNIw3`+`1NZd?_)iU zKJ{WSy<*n_mIKU{#OeKl~L?f6=`#*+X4Iga9T#ewJYOZR%Gsj*Yo8=UJ zU(=X8Y21)B1<6eTJ4T`8_r$p*nxy6-%ZBo{P_X`8vgLR_VZ-;CAgg*`Ng3q!5za?( zVrbn51-rYRz=Q|yz+gKElAcr-OPs#PBJlQsNID2~%)_<-SkH=(q1E-~0o_^7nB7 literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-13_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3ecf9178ca4a99c8ee5ba3e2f8efe415123855b6 GIT binary patch literal 2325 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 literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_nrm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_nrm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3421bc0658fa6554dc5f7bf8360a5b7d35622cd2 GIT binary patch literal 2164 zcmbW1dpy(s9>>44*%6!zPO;^5JKqAFknvE;+aDMwP%(lbuwoX$Oy z_eXx=rOQQEs8?yl6^zQN>Y7?6tLb(#`%cT3+)V9~c}O9(gs&pLqN3 zy>Rlw)U@b}WbW(yw}tPEORHQE0QwIsaQ%;5Y9N;!3KwI}H%QhUSZ3G|m$w4Y@E9oWhLi|k)u|K{QY%1{XS zcu+OK4QTtg%3xc>fJ zoR{)qN|oo03+^3jQ3TH*H!)Vo%`7UlU*feNaK+IYl6jhMZ#|0Znj4r27gq0T=nW*+ z^lX29dAiF>MY-D&RebDnk)eqGGNziSLv=jY(r%mTHUmH+P|1YS_#2IV=-A{C?0WdI93n zLdy0H(wQ(yc51LH$}SHb6YZg*5^t2QvB1tiO9jD~W#8%_k8zo$pU%}h|J+@(Nh*wF zqtG$W$J*+K8zcb%u0hv!Fz2JFe@m#-&a)eGrnzRYV^#oj&r3p^>moDf_3ijrbM>;c zKl_RLbsZix2uF@G>Ft-_n^3mC8ELig29vBvAS&7(zpaNN8MI@3sEO$8*}?an8?0Q! zM{FQlM7vL9r-ps05o5oKmpL^WS(0j<#+{0TL6n$asbHVHbcT!r8)(v*5W7#$>Bc4X z>n=%25$CdI>o_t=!^1LejpP0TS))pw@!EH;B;p+tlJ!3&V)`bPux?$^WF$Q~7T%P2 zrEZr=AK}1=u!11mbgq25OcYo@AmmqcCvJVTHLi3hW8R_GJbIE& znl*G#_B3={!+p+gAmf*~>8PHN_)8yHXCumU==zj|7^xuSxJ*+2tP>q_B0D|Ekk)gW zuH~y!*+&g~`~9k%<9N^K@lD<75XP-;mVb=BrLTB@6!+L9SzJtrS^CA)^KIB%*wKMW=`@x z3zQO?=!x%auh)f0M9pWH`CJ81AYp}bTxf?b0~BZXoybDgc3D#cU&<@KAaLxsHLS}x zQfBK%E;DzUS~IkTRnSatABeECmzL5-n7!I@goe=K_==&qqq*NBLTwdQ1?MqMzYdO^ z$|q6ZRLCa!avNrSu1#gKiKAH=t%nQPYibQdM$!SZU(9btjhHTT`U>3A!|jnSoO1mO z)}@Y9$>Ua3`Wc^2E(_y!lqhMK{kYjuOlJ0YxokN)d~w=ejC~)!%wepP&aC?L$ESi) z-8O4e{0acd-jrEXa!`dvn7Q3V?-y>|>C=X3QO_KT>6Hxy3_74MX;u3yNqvO< t)8O&!Hvy>A^zZvz)AbWNIV_0V(NNk7kQzL6rX)Z~M9QfP2&Jry{sot{{{jF2 literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_orm.jpg b/examples/prd493-terrain-splat/public/world/terrain/tex/layer-15_orm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a566d9e3d1a4ab37a399249068df7f47a1b88eca GIT binary patch literal 2364 zcmbW2eK^y5AIE=Vj7ba=>zv5L80i$&!*V#uh;C1_4NJr!4-+RuTv8_`My<(cyIVr` zXr0K&%8{o$9nZE{>Lf*`+(mf^?M(M|-Sul29Vqj#n#lX-I@x7%9VypRghK8m)OwEy2C>xZqiQO(c>s^-CDC>Sj1@@PmazMA8LK zsNohPV}vEr${J|tIVNb6BJb@S(6ioU#GU`NhOl(|o%IVa!GwB)G z7cb>p&ds9~UN52-m)u}5%Pa0wvN>EHzvh0esILA&gSe%&t^G+yXIJ;o@Q6hEbaYHM z@oMt*o2luyGxCq~3!gqOE-kNo+28^Js{h1Np8t^xrsPrqgH^$h4K9#MnsTecz-rrf ztLq)~hlHQfH?#j$!@!kRR^6;=?tuFMk4PGX8d^9`Sbp3<`$qQPfnE5&$o>WPZ!Q_I zNfo3lo+=D*0px88;K8h{W?M*qfWXB3Lsg4!X&Bmz8R%So|2p^TC5!d;@KDcH1@J^v z4sKp1+Px~pu)qzBecT=fqPXytO2u(RZI<5K5}#MKw{0n)x*QlD}U2G;oZRG*#x14lHnyo6kj z1u31j?U>)lI7GM|ITfADgBt`5U8j`z7vM1=6>wzPE?(MVjwr5UY`ZMpl$phYba@ZG zF&2>WFVM9`e&YN2(F{Hp?c!IEkovTTCpK zMe=ubgVUWNeV2zhCq`$7GCQAF7p5?WbAF)~ySbFnwMhmbn^JN*E2xo)U4tST?`pIhoC|LtHv0=~6 ze%$XaujU@N!wtV);e9E3KpJcp=MZdV6w6_a1zX|=s!f9sbM6IE_`G9IJR)JQ43@Wj zeQ5_c5UrMJfSPvS_o6O%HuL`#|ZEy6Xj&E!(#N^AdG+~l+pN_#Y)`s8kbenUrS zG6DAVsE-q+?^gJ9z{%In*l2Im0v|QB#1LlGOnLannr;kKYp=l2CzILV6cNd-)bgfN zida{)wrsVzT8Z4osqQZcma)^MMU$(oJMeePuh{Jjr(E~DNj$-9gQAVK_j+Qo1voDz zy0#%FL&Ea<33u-k&nIrKZ2MTS51o(byTdXt^l|F0=I)yp>}k#`6v~a~dEL%mkO`rW z%g9KiFnDUc_xQ8uKA~2j8(Mfc^l-NWvpm)_ah2L~&$3^bc|&SOHu)hN>Ggx(v679krT^W4#M&6fEv^;`34` zd%4+}@1&0^=8E5${&D&czJ5hae8Oz);fss4@UAEE+pK8K1}Upq7(C|YKH*6xWjErX zQnX2}eVa8urmV7Kf$;3@SFe0;Q*>}scwJtdd^3(xPLai*dnD9)n+y*a{w|%)JJZ@- zCHLf29Bm%hbyBDR&IZoCR{+UFg~RHk!H24y91NAASM+0ESYL0`M9`2YV-|~FffxHR z>|{1I=JKR}gjgHGwuLASrb^U^dI_+0cKo$dZ5_CEx7yAfRHlcyxBF}~M> z0|#k#vEEjD+>tj+P$G6n)|_&rdhi)>Ec*A$2Lx#Ndu{TQklZGAPx~Q$Mfe@;H-Zphx8~d{&6*=Z;hOA+8G-~LU|%u^ip&bV}Ah?^+AIG literal 0 HcmV?d00001 diff --git a/examples/prd493-terrain-splat/public/world/world.json b/examples/prd493-terrain-splat/public/world/world.json new file mode 100644 index 0000000000..2f8a6a0991 --- /dev/null +++ b/examples/prd493-terrain-splat/public/world/world.json @@ -0,0 +1,27 @@ +{ + "assets": {}, + "cellSize": 64, + "cells": [], + "extent": { + "maxY": 3.0, + "minX": -63.0, + "minY": -3.0, + "minZ": -63.0, + "sizeX": 126.0, + "sizeZ": 126.0 + }, + "placements": "placements.bin", + "terrain": { + "columns": 64, + "heightMax": 3.0, + "heightMin": -3.0, + "heightmap": "terrain/heightmap.u16", + "layers": { + "splat": "terrain/splat.rgba8", + "table": "terrain/layers.json" + }, + "rows": 64, + "spacing": 2.0 + }, + "version": 1 +} diff --git a/examples/prd493-terrain-splat/scripts/make_world.py b/examples/prd493-terrain-splat/scripts/make_world.py new file mode 100644 index 0000000000..09899b5bc2 --- /dev/null +++ b/examples/prd493-terrain-splat/scripts/make_world.py @@ -0,0 +1,265 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Writes this example's world package: a heightfield, one mask plane set and sixteen terrain +# layers with an albedo, a normal and an ORM map each. Sixteen is the point — every map is its own +# uncompressed JPEG, which is the shape that used to bind one sampler per map and stop at WebGPU's +# sixteen sampled textures a stage. +# +# The layer table and the texture files are authored here and assembled by the shipped recipe +# (`export_terrain_layers` in export_world.py), so the package is the one a DCC export produces and +# not a second format. Run it through Blender, which is also what writes the JPEGs: +# +# blender --background --factory-startup --python examples/prd493-terrain-splat/scripts/make_world.py \ +# -- --out examples/prd493-terrain-splat/public/world +# +# The maps are proof fixtures, not art: values are written straight into 8-bit sRGB JPEGs, so the +# normal and ORM channels carry the transfer curve of the file format rather than exact numbers. + +import colorsys +import json +import math +import os +import sys + +import bpy + +LAYER_COUNT = 16 +TEXTURE_SIZE = 128 +SPLAT_SIZE = 128 +HEIGHT_COLUMNS = 64 +HEIGHT_ROWS = 64 +SPACING = 2.0 +RECIPE = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "packages", "core", "gpl", "recipes") +) + + +def fail(message): + sys.stderr.write("TN_PRD493_ERROR: %s\n" % message) + sys.stderr.flush() + os._exit(2) + + +def parse_args(): + argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + out = None + index = 0 + while index < len(argv): + if argv[index] == "--out" and index + 1 < len(argv): + out = argv[index + 1] + index += 1 + index += 1 + if out is None: + fail("make_world needs --out ") + raise SystemExit(2) + return os.path.abspath(out) + + +def write_image(path, width, height, pixel, file_format="JPEG", quality=80): + """One image written from a `(x, y) -> (r, g, b)` function in 0..1, rows bottom-up.""" + image = bpy.data.images.new(os.path.basename(path), width=width, height=height, alpha=False) + try: + pixels = [] + for y in range(height): + for x in range(width): + r, g, b = pixel(x, y) + pixels.extend((r, g, b, 1.0)) + image.pixels.foreach_set(pixels) + image.filepath_raw = path + image.file_format = file_format + image.save(quality=quality) + finally: + bpy.data.images.remove(image) + + +def noise(x, y, seed): + """A cheap deterministic value in -1..1: layered sines, so every map tiles without seams.""" + return ( + math.sin((x + seed * 13.0) * 0.37 + seed) * 0.5 + + math.sin((y + seed * 7.0) * 0.23 - seed) * 0.3 + + math.sin((x + y) * 0.11 + seed * 3.0) * 0.2 + ) + + +def write_layer_maps(tex, layer): + """The three maps of one layer: a tinted albedo with a visible weave, a normal, and an ORM.""" + hue, saturation, value = layer["_hsv"] + tint = layer["tint"] + roughness = layer["roughness"] + metalness = layer["metalness"] + seed = layer["_seed"] + period = 8.0 if layer["_weave"] else 5.0 + albedo_r, albedo_g, albedo_b = colorsys.hsv_to_rgb(hue, saturation, value) + weave = lambda x, y: ((x // (TEXTURE_SIZE / period)) + (y // (TEXTURE_SIZE / period))) % 2 == 0 + write_image( + os.path.join(tex, "%s_diff.jpg" % layer["id"]), + TEXTURE_SIZE, + TEXTURE_SIZE, + lambda x, y: ( + (albedo_r if weave(x, y) else albedo_r * 0.78) * tint[0], + (albedo_g if weave(x, y) else albedo_g * 0.78) * tint[1], + (albedo_b if weave(x, y) else albedo_b * 0.78) * tint[2], + ), + ) + slope = 0.25 + 0.35 * roughness + write_image( + os.path.join(tex, "%s_nrm.jpg" % layer["id"]), + TEXTURE_SIZE, + TEXTURE_SIZE, + lambda x, y: ( + 0.5 + noise(x, y, seed) * slope * 0.35, + 0.5 + noise(x, y, seed + 5) * slope * 0.35, + 1.0, + ), + ) + # Occlusion in r, roughness in g, metalness in b, exactly as the loader reads them. + write_image( + os.path.join(tex, "%s_orm.jpg" % layer["id"]), + TEXTURE_SIZE, + TEXTURE_SIZE, + lambda x, y: ( + 0.55 + 0.45 * (0.5 + 0.5 * noise(x, y, seed + 9)), + roughness * (0.85 + 0.15 * noise(x, y, seed + 11)), + metalness, + ), + ) + + +def build(out): + source = os.path.join(out, "dcc") + tex = os.path.join(source, "tex") + os.makedirs(tex, exist_ok=True) + os.makedirs(os.path.join(out, "terrain"), exist_ok=True) + + layers = [] + masks = {} + for index in range(LAYER_COUNT): + hue = (0.09 + index * 0.061) % 1.0 + layers.append( + { + "_hsv": (hue, 0.25 + 0.05 * (index % 4), 0.35 + 0.03 * (index % 5)), + "_seed": index + 1, + "_weave": index % 3 == 0, + "id": "layer-%02d" % index, + "metalness": 0.0 if index % 5 else 0.35, + "normal": True, + "orm": True, + "roughness": 0.45 + 0.05 * (index % 6), + "tile": 3.0 + 0.5 * (index % 4), + "tint": [0.75 + 0.05 * (index % 3), 0.8 + 0.05 * (index % 2), 0.85], + } + ) + + for layer in layers: + write_layer_maps(tex, layer) + + # Fifteen masked layers over three channels per mask image: five planes, each band a gradient. + for plane in range(5): + name = "mask-%d" % plane + masks[name] = {"image": "%s.png" % name, "channels": "rgb"} + write_image( + os.path.join(source, "%s.png" % name), + SPLAT_SIZE, + SPLAT_SIZE, + lambda x, y, plane=plane: ( + 1.0 if (x + plane * 24) % SPLAT_SIZE < SPLAT_SIZE / 3 else 0.0, + 1.0 if (x + plane * 24) % SPLAT_SIZE < 2 * SPLAT_SIZE / 3 else 0.0, + 1.0 if (x + plane * 24) % SPLAT_SIZE >= SPLAT_SIZE / 3 else 0.0, + ), + file_format="PNG", + ) + + def table_entry(layer, masked): + entry = {key: value for key, value in layer.items() if not key.startswith("_")} + if masked: + entry.update( + { + "channel": "rgb"[(layer["_seed"] - 1) % 3], + "hi": 0.55 + 0.1 * (layer["_seed"] % 3), + "lo": 0.2, + "mask": "mask-%d" % ((layer["_seed"] - 2) // 3), + } + ) + return entry + + table = { + "base": table_entry(layers[0], False), + "breakup": {"push": 0.12, "scale": 0.08}, + "layers": [table_entry(layer, True) for layer in layers[1:]], + "macro": {"max": 1.08, "min": 0.92, "scale": 0.02}, + "masks": masks, + "splatSize": SPLAT_SIZE, + "textures": { + "diff": "{id}_diff.jpg", + "nrm": "{id}_nrm.jpg", + "orm": "{id}_orm.jpg", + "search": ["."], + }, + } + table_path = os.path.join(source, "layers.json") + with open(table_path, "w") as handle: + json.dump(table, handle, indent=2, sort_keys=True) + handle.write("\n") + + # The shipped recipe writes splat.rgba8, layers.json and the maps, through the same path a + # game's DCC export takes. + sys.path.insert(0, RECIPE) + import export_world + + paths = export_world.export_terrain_layers(out, table_path, source) + + heights = [] + for row in range(HEIGHT_ROWS): + for column in range(HEIGHT_COLUMNS): + metres_x = (column - HEIGHT_COLUMNS / 2) * SPACING + metres_z = (row - HEIGHT_ROWS / 2) * SPACING + heights.append( + int( + max(0.0, min(65535.0, 32768.0 + 26000.0 * math.sin(metres_x * 0.06) * math.cos(metres_z * 0.05))) + ) + ) + with open(os.path.join(out, "terrain", "heightmap.u16"), "wb") as handle: + handle.write(b"".join(value.to_bytes(2, "little") for value in heights)) + + extent = (HEIGHT_COLUMNS - 1) * SPACING + manifest = { + "assets": {}, + "cellSize": 64, + "cells": [], + "extent": { + "maxY": 3.0, + "minY": -3.0, + "sizeX": extent, + "sizeZ": extent, + "minX": -extent / 2, + "minZ": -extent / 2, + }, + "placements": "placements.bin", + "terrain": { + "columns": HEIGHT_COLUMNS, + "heightMax": 3.0, + "heightMin": -3.0, + "heightmap": "terrain/heightmap.u16", + "layers": paths, + "rows": HEIGHT_ROWS, + "spacing": SPACING, + }, + "version": 1, + } + with open(os.path.join(out, "world.json"), "w") as handle: + json.dump(manifest, handle, indent=2, sort_keys=True) + handle.write("\n") + with open(os.path.join(out, "placements.bin"), "wb") as handle: + handle.write(b"") + + shutil.rmtree(source, ignore_errors=True) + sys.stdout.write( + "TN_PRD493_WORLD layers=%d maps=%d splatPlanes=%d out=%s\n" + % (LAYER_COUNT, LAYER_COUNT * 3, len(masks), out) + ) + + +if __name__ == "__main__": + import shutil + + build(parse_args()) diff --git a/examples/prd493-terrain-splat/src/game.ts b/examples/prd493-terrain-splat/src/game.ts new file mode 100644 index 0000000000..0902948ba7 --- /dev/null +++ b/examples/prd493-terrain-splat/src/game.ts @@ -0,0 +1,85 @@ +import { type ICtx, Scene, defineGame } from "@threenative/core"; +import { playtest } from "@threenative/core/playtest"; +import { WorldCells, loadTerrainSplat } from "@threenative/core/world"; +import { Color, DirectionalLight, type PerspectiveCamera } from "three"; +import { SKY_COLOR, addDaylight, frameTerrain, orbitPose } from "./render/terrain-look.js"; + +const BUDGETS = { bytes: 8_000_000, instances: 2_000, residentCells: 9 }; + +/** + * Sixteen terrain layers, each with an albedo, a normal and an ORM map, over one streamed + * heightfield. Every map is an uncompressed JPEG — the shape that used to bind one sampler per map, + * 48 textures against WebGPU's 16 sampled textures a stage. Same-size layers now stack into one + * array texture per set, and the material reports what it costs in `userData.TN_TERRAIN_SPLAT`, so + * this scene publishes those numbers and the playtest scenario asserts them: a regression to one + * sampler per map fails the run instead of merely looking wrong. + */ +class TerrainSplatScene extends Scene { + static override initialState = { layers: -1, samplers: -1, stacked: -1 }; + + #world: WorldCells | undefined; + #surface: Awaited> | undefined; + #sun = new DirectionalLight(); + #elapsed = 0; + + override async load(ctx: ICtx): Promise { + // The renderer is what stacks the uncompressed layers: one GPU copy per layer into one array + // texture per set, so the surface samples four textures instead of forty-eight. + this.#surface = await loadTerrainSplat({ + assets: ctx.assets, + renderer: ctx.renderer, + url: "world/world.json", + }); + this.#world = await WorldCells.load({ + assets: ctx.assets, + budgets: BUDGETS, + follow: ctx.camera, + ring: 2, + surface: this.#surface, + terrain: { tileResolution: 65 }, + url: "world/world.json", + }); + } + + override enter(ctx: ICtx): void { + const world = this.#world; + if (world === undefined) throw new Error("TerrainSplat.enter ran before load() resolved."); + ctx.add(world); + ctx.scene.background = new Color(SKY_COLOR); + addDaylight(ctx.scene, this.#sun); + frameTerrain(ctx.camera as PerspectiveCamera); + ctx.entities.add("splat", { + debug: () => ({ + ...this.#costs(), + residentCells: world.stats().residentCells, + }), + dispose: () => world.dispose(), + object: world, + }); + } + + override update(ctx: ICtx, dt: number): void { + this.#elapsed += dt; + const pose = orbitPose(this.#elapsed); + ctx.camera.position.copy(pose.position); + ctx.camera.lookAt(pose.target); + } + + /** The marker's own numbers, so the runner asserts the cost rather than a screenshot's opinion. */ + #costs(): { layers: number; samplers: number; stacked: number } { + const marker = String(this.#surface?.userData.TN_TERRAIN_SPLAT ?? ""); + const number = (key: string): number => { + const found = new RegExp(`${key}=(\\d+)`, "u").exec(marker); + return found === null ? -1 : Number(found[1]); + }; + return { layers: number("layers"), samplers: number("samplers"), stacked: number("stacked") }; + } +} + +export default defineGame({ + plugins: [playtest()], + render: { preferWebGPU: true }, + scenes: { terrainSplat: TerrainSplatScene }, + start: "terrainSplat", + step: 1 / 60, +}); diff --git a/examples/prd493-terrain-splat/src/main.ts b/examples/prd493-terrain-splat/src/main.ts new file mode 100644 index 0000000000..3d75795336 --- /dev/null +++ b/examples/prd493-terrain-splat/src/main.ts @@ -0,0 +1,3 @@ +import game from "./game.js"; + +void game.start(); diff --git a/examples/prd493-terrain-splat/src/render/terrain-look.ts b/examples/prd493-terrain-splat/src/render/terrain-look.ts new file mode 100644 index 0000000000..b7ff1a945b --- /dev/null +++ b/examples/prd493-terrain-splat/src/render/terrain-look.ts @@ -0,0 +1,47 @@ +/** + * The look of this example's world: the sky, the sun and where the camera stands. Everything about + * how the terrain is textured comes from the package's own table and the recipe that wrote it. + */ +import { + Color, + type DirectionalLight, + HemisphereLight, + type PerspectiveCamera, + Vector3, +} from "three"; + +export const SKY_COLOR = 0x0b1a2a; +export const START_POSITION: readonly [number, number, number] = [26, 9, 26]; +export const LOOK_AT: readonly [number, number, number] = [-4, 0, -4]; + +/** A slow orbit, so a playtest capture and a judge's screenshot share one repeatable pose. */ +export function orbitPose(seconds: number): { + readonly position: Vector3; + readonly target: Vector3; +} { + const angle = 0.55 + seconds * 0.06; + return { + position: new Vector3(Math.cos(angle) * 34, 11, Math.sin(angle) * 34), + target: new Vector3(...LOOK_AT), + }; +} + +export function addDaylight( + scene: { add: (node: HemisphereLight | DirectionalLight) => void }, + sun: DirectionalLight, +): HemisphereLight { + const sky = new HemisphereLight(0xbfd8ff, 0x2a2f22, 2.4); + sun.color = new Color(0xfff3e0); + sun.intensity = 2.8; + sun.position.set(-60, 90, 40); + scene.add(sky); + scene.add(sun); + return sky; +} + +/** Point the camera at the one pose both arms of a capture share. */ +export function frameTerrain(camera: PerspectiveCamera): void { + const pose = orbitPose(0); + camera.position.copy(pose.position); + camera.lookAt(pose.target); +} diff --git a/examples/prd493-terrain-splat/tsconfig.json b/examples/prd493-terrain-splat/tsconfig.json new file mode 100644 index 0000000000..ea5c89279a --- /dev/null +++ b/examples/prd493-terrain-splat/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "types": ["vite/client"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/core/src/world-terrain-splat.ts b/packages/core/src/world-terrain-splat.ts index abeb11a7f2..6d7c7c942d 100644 --- a/packages/core/src/world-terrain-splat.ts +++ b/packages/core/src/world-terrain-splat.ts @@ -483,9 +483,9 @@ export async function loadTerrainSplat(options: ILoadTerrainSplatOptions): Promi material.aoNode = ormSample.r; material.roughnessNode = ormSample.g; material.metalnessNode = ormSample.b; - console.info( - `TN_TERRAIN_SPLAT layers=${String(all.length)} samplers=${String(samplers)} ` + - `stacked=${String(stackedSets)}`, - ); + // One line, printed and kept on the material: what this surface costs, readable by a game that + // wants to assert it rather than scrape a console. + material.userData.TN_TERRAIN_SPLAT = `layers=${String(all.length)} samplers=${String(samplers)} stacked=${String(stackedSets)}`; + console.info(`TN_TERRAIN_SPLAT ${material.userData.TN_TERRAIN_SPLAT as string}`); return material; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07c369e7af..a6f27e3532 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -577,6 +577,28 @@ importers: specifier: 'catalog:' version: 8.2.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5) + examples/prd493-terrain-splat: + dependencies: + '@threenative/core': + specifier: workspace:* + version: link:../../packages/core + '@threenative/playtest': + specifier: workspace:* + version: link:../../packages/playtest + three: + specifier: 'catalog:' + version: 0.185.1(patch_hash=083710b3f650299016af6b9a0463e600517a5686f7f1713dead2b0f7ab9218d0) + devDependencies: + '@types/three': + specifier: 0.185.3 + version: 0.185.3 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: 'catalog:' + version: 8.2.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.5) + examples/quarry: dependencies: '@threenative/core': From e3016e076b389bf61dbb9219a827f23bd1043a24 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 4 Oct 2026 13:28:33 -0700 Subject: [PATCH 4/6] test(world): the sixteen-layer splat package on the native desktop host The browser lane proves the stacking; the hosts that matter most are the ones with no KTX2 transcoder, and there nothing stacked before. This gives the example a native identity (threenative.config.ts, one description read by both the web dev server and `threenative build`) and the same assertions as a desktop scenario: sixteen layers, at most four samplers, three stacked sets. orbitSeconds is new because a native run that renders one frozen frame would otherwise satisfy every cost assertion; the camera pose has to advance. --target desktop on the built host: pass, layers=16 samplers=4 stacked=3, orbitSeconds 2.03 -> 5.03, diagnostics clean, 0 console errors. The web scenario with the same assertion is pass on the software-and-hardware WebGPU recipe; world-terrain-splat.spec.ts 11/11. --- examples/prd493-terrain-splat/README.md | 10 ++++ examples/prd493-terrain-splat/package.json | 6 ++- .../terrain-splat-desktop.playtest.json | 49 +++++++++++++++++++ .../playtests/terrain-splat.playtest.json | 5 ++ examples/prd493-terrain-splat/src/game.ts | 1 + .../threenative.config.ts | 22 +++++++++ pnpm-lock.yaml | 3 ++ 7 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 examples/prd493-terrain-splat/playtests/terrain-splat-desktop.playtest.json create mode 100644 examples/prd493-terrain-splat/threenative.config.ts diff --git a/examples/prd493-terrain-splat/README.md b/examples/prd493-terrain-splat/README.md index 45a875acab..f0b5b58a62 100644 --- a/examples/prd493-terrain-splat/README.md +++ b/examples/prd493-terrain-splat/README.md @@ -24,6 +24,16 @@ node packages/playtest/dist/runner/cli.js examples/prd493-terrain-splat/playtest --browser-recipe webgpu --headed ``` +The same scenario on the native host, where the GPU copies do the same stacking: + +```sh +pnpm --filter prd493-terrain-splat build:native +node packages/playtest/dist/runner/cli.js examples/prd493-terrain-splat/playtests/terrain-splat-desktop.playtest.json \ + --target desktop \ + --executable examples/prd493-terrain-splat/dist-native/prd493-terrain-splat \ + --build-report examples/prd493-terrain-splat/dist-native/prd493-terrain-splat.build-report.json +``` + ## Regenerate the world package The package is what a DCC export produces, not a second format: the script writes the layer table diff --git a/examples/prd493-terrain-splat/package.json b/examples/prd493-terrain-splat/package.json index f41366b179..aa06aa3c57 100644 --- a/examples/prd493-terrain-splat/package.json +++ b/examples/prd493-terrain-splat/package.json @@ -7,7 +7,8 @@ "build": "vite build", "dev": "vite", "typecheck": "tsc --noEmit -p tsconfig.json", - "world": "blender --background --factory-startup --python scripts/make_world.py -- --out public/world" + "world": "blender --background --factory-startup --python scripts/make_world.py -- --out public/world", + "build:native": "node ../../packages/create-threenative/dist/threenative.js build --target desktop" }, "dependencies": { "@threenative/core": "workspace:*", @@ -17,6 +18,7 @@ "devDependencies": { "@types/three": "0.185.3", "typescript": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "@threenative/runtime-native": "workspace:*" } } diff --git a/examples/prd493-terrain-splat/playtests/terrain-splat-desktop.playtest.json b/examples/prd493-terrain-splat/playtests/terrain-splat-desktop.playtest.json new file mode 100644 index 0000000000..1c8e17f1a0 --- /dev/null +++ b/examples/prd493-terrain-splat/playtests/terrain-splat-desktop.playtest.json @@ -0,0 +1,49 @@ +{ + "name": "terrain-splat-sixteen-layers-desktop", + "schemaVersion": 1, + "subject": "the native host samples the same sixteen uncompressed terrain layers through four textures", + "target": "desktop", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 60, + "artifacts": { + "screenshots": "before-after" + }, + "steps": [ + { + "kind": "wait", + "label": "layers-resident", + "waitTicks": 180, + "release": true + } + ], + "assert": { + "components": [ + { + "allowTrivial": "Sixteen layers is the whole package: the table ships no more.", + "component": "layers", + "entity": "splat", + "equals": 16 + }, + { + "allowTrivial": "The splat planes plus one array per set: albedo, normal and ORM.", + "component": "samplers", + "entity": "splat", + "lte": 4 + }, + { + "allowTrivial": "One array texture per set, whatever the layer count.", + "component": "stacked", + "entity": "splat", + "equals": 3 + }, + { + "changed": true, + "component": "orbitSeconds", + "entity": "splat" + } + ] + } +} diff --git a/examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json b/examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json index 7d7328356c..092306a2e9 100644 --- a/examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json +++ b/examples/prd493-terrain-splat/playtests/terrain-splat.playtest.json @@ -44,6 +44,11 @@ "component": "stacked", "entity": "splat", "equals": 3 + }, + { + "changed": true, + "component": "orbitSeconds", + "entity": "splat" } ], "visual": [ diff --git a/examples/prd493-terrain-splat/src/game.ts b/examples/prd493-terrain-splat/src/game.ts index 0902948ba7..6617a8e2fd 100644 --- a/examples/prd493-terrain-splat/src/game.ts +++ b/examples/prd493-terrain-splat/src/game.ts @@ -51,6 +51,7 @@ class TerrainSplatScene extends Scene { ctx.entities.add("splat", { debug: () => ({ ...this.#costs(), + orbitSeconds: Number(this.#elapsed.toFixed(2)), residentCells: world.stats().residentCells, }), dispose: () => world.dispose(), diff --git a/examples/prd493-terrain-splat/threenative.config.ts b/examples/prd493-terrain-splat/threenative.config.ts new file mode 100644 index 0000000000..3f36f11623 --- /dev/null +++ b/examples/prd493-terrain-splat/threenative.config.ts @@ -0,0 +1,22 @@ +import type { IThreeNativeConfig } from "@threenative/core"; + +// The native proof needs an app identity and a native entry; the web dev server reads the same file +// through `threenative build`, so there is one description of this example. +const config: IThreeNativeConfig = { + app: { + id: "com.threenative.prd493terrainsplat", + name: "PRD-493 Terrain Splat", + version: "1.0.0", + build: 1, + }, + display: { + fullscreen: true, + keepScreenOn: true, + orientation: "landscape", + }, + nativeEntry: "src/game.ts", + renderer: { preferWebGPU: true }, + ui: { renderer: "native" }, +}; + +export default config; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6f27e3532..fca0ebe73a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -589,6 +589,9 @@ importers: specifier: 'catalog:' version: 0.185.1(patch_hash=083710b3f650299016af6b9a0463e600517a5686f7f1713dead2b0f7ab9218d0) devDependencies: + '@threenative/runtime-native': + specifier: workspace:* + version: link:../../packages/runtime-native '@types/three': specifier: 0.185.3 version: 0.185.3 From 499506fa360a32b45335a12c8fb46a0e7921dbb9 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 4 Oct 2026 13:48:21 -0700 Subject: [PATCH 5/6] feat(world): a conformance row for sixteen terrain layers on native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosts this PRD is for cannot see KTX2, so nothing stacked there, and nothing proved it: the browser lane cannot fail on a path the browser never takes. terrain-splat-array builds a sixteen-layer package with an albedo, a normal and an ORM map per layer — the shape Android and iOS ship — through loadTerrainSplat, and the scene throws unless the marker reads layers=16 samplers=4 stacked=3, so the row fails on a regression to one sampler per map rather than merely looking different. The 48 maps are DataTextures derived from a pure function of their cell and the mask grid gives each layer its own region, so both lanes derive the same frame from nothing but the scene file: the desktop host matches the browser reference at pixelMismatchRatio 0 and perceptualDeltaE 0 over 1280x720. The Android leg of the row is left open, not claimed: :app:verifyV8Dependency refuses to bundle without a V8 build receipt that only an hours-long source cross-compile writes, and the emulator was up and waiting when it stopped. Gates: parity --target web pass (reference captured); parity --target desktop pass, 0/0 metrics, 0 gpu validation errors; runtime-native conformance suite 76/76; pnpm typecheck and pnpm lint exit 0; pnpm check:docs exit 0. --- ...93-terrain-layers-past-sixteen-textures.md | 21 ++- .../runtime-native/conformance/registry.json | 15 ++ .../scenes/shared/terrain-splat-array.js | 150 ++++++++++++++++++ 3 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 packages/runtime-native/conformance/scenes/shared/terrain-splat-array.js diff --git a/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md b/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md index c7e26b16b9..db75e6540a 100644 --- a/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md +++ b/docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.md @@ -1,6 +1,6 @@ # PRD-493 — Terrain layers past sixteen textures -**Status:** PROPOSED +**Status:** PARTIAL — phases 1, 2 and the desktop half of 3 landed; the Android row and both Machinefall acceptance criteria are blocked on what is listed under `## Blocked on` **Complexity:** 3 (LOW) — 1–5 implementation files (`world-terrain-splat.ts`, the two identical `export_world.py` recipe copies) (+1); Machinefall re-exports its table and releases separately (+2); risk override: none **Owner:** João **Depends on:** None @@ -28,8 +28,13 @@ The blend curve keeps its current form (mask, `lo`/`hi`, breakup push, macro noi ## Acceptance Criteria -- [ ] AC-1 [local]: Machinefall's map-walk with ORM layers is judged at or above develop on every pose, with no seam or tiling artefact named. proof: `pnpm visuals:world --before --after ` plus 3 verdicts. -- [ ] AC-2 [local]: the same walk's `gpuMain` p95 rises by no more than 0.5 ms over develop on the RTX 2080. proof: `TN_FRAME_BUDGET` from `node packages/playtest/dist/runner/cli.js perf`, 3 interleaved runs. +- [ ] AC-1 [local]: Machinefall's map-walk with ORM layers is judged at or above develop on every pose, with no seam or tiling artefact named. proof: `pnpm visuals:world --before --after ` plus 3 verdicts. Not attempted: Machinefall (`apps/client`) is a private repo this tree cannot see, so there is no walk to run or to capture here; the branch's own sixteen-layer package is captured instead (`/home/joao/projects/threenative/threenative-engine/.afk/scratch/prd493-captures/phase-1-2/`, `after.png` this branch against a develop frame that never reaches one — 34 samplers against a limit of 16). A judge still has to say whether the ORM layers look right. +- [ ] AC-2 [local]: the same walk's `gpuMain` p95 rises by no more than 0.5 ms over develop on the RTX 2080. proof: `TN_FRAME_BUDGET` from `node packages/playtest/dist/runner/cli.js perf`, 3 interleaved runs. Not attempted: same private repo, and the budget names an RTX 2080 this machine does not have. + +## Blocked on + +- **Machinefall's own walk (AC-1, AC-2)** — the private repo holding `apps/client/src/level/World.ts`; the owner has to run the walk and the three interleaved perf runs, or make it reachable. +- **The Android conformance row** — one Android V8 build receipt on a Linux x64 host (`pnpm native:build`); without it `:app:verifyV8Dependency` refuses to bundle the APK, which is the only step the row has left. ## Integration Ledger @@ -66,7 +71,11 @@ Uncompressed layers stack through `renderer.copyTextureToTexture` per layer (`IL layer catches the light. #### Phase 3: Native and mobile sample the arrays -**Status:** NOT STARTED +**Status:** LANDED (desktop) / PARTIAL (Android) **Files:** `packages/runtime-native/conformance/scenes/shared/terrain-splat-array.js` (new), `registry.json` -- [ ] A conformance row renders a 16-layer uncompressed splat package (albedo, normal and ORM per layer) on desktop within tolerance of the browser reference, with `samplers=4` in its marker. proof: `pnpm parity --target desktop --only-tests terrain-splat-array`. -- [ ] The same row on the Android emulator, which is the host without a KTX2 transcoder. proof: `pnpm parity --target android --only-tests terrain-splat-array`. +- [x] A conformance row renders a 16-layer uncompressed splat package (albedo, normal and ORM per layer) on desktop within tolerance of the browser reference, with `samplers=4` in its marker. proof: `pnpm parity --target web --only-tests terrain-splat-array` captured the browser reference (pass, 1280x720, non-uniform), then `pnpm parity --target desktop --only-tests terrain-splat-array` — pass, 16.6 s, `metrics.pixelMismatchRatio: 0`, `metrics.perceptualDeltaE: 0`, 0 GPU validation errors; the scene throws unless the marker reads `layers=16 samplers=4 stacked=3`. `packages/runtime-native` conformance suite 76/76 green. Captures for a judge: `/home/joao/projects/threenative/threenative-engine/.afk/scratch/prd493-captures/phase-3/` (`before.png` the browser reference, `after.png` the desktop-native frame, same pose; develop has no such row to capture from). +- [ ] The same row on the Android emulator, which is the host without a KTX2 transcoder. proof: `pnpm parity --target android --only-tests terrain-splat-array` — attempted, `fail` at `phase: build`: `:app:verifyV8Dependency` refuses to bundle because `packages/runtime-native/third_party/v8-android/build-receipt.json` does not exist, and the receipt is written only by a V8 source cross-compile for arm64-v8a + x86_64 (`pnpm native:build`, hours; `provisionAndroidV8` deletes an unverifiable cache and rebuilds). The emulator itself is up (`emulator-5554`, API 35, x86_64) and the lane got as far as gradle, so the blocker is that dependency and not the device. Unblocks when one Android V8 build has been produced on a Linux x64 host. + +The row builds its 48 maps as `DataTexture`s from a pure function of their cell, so both lanes +derive the same bytes; a 4x4 mask grid gives each layer its own region, which is what makes one +sampled texture per set visible in a single frame rather than asserted only by the marker. diff --git a/packages/runtime-native/conformance/registry.json b/packages/runtime-native/conformance/registry.json index 3dc603cdf9..5d4879c9b1 100644 --- a/packages/runtime-native/conformance/registry.json +++ b/packages/runtime-native/conformance/registry.json @@ -788,6 +788,21 @@ "desktopGate": true, "required": true }, + { + "id": "terrain-splat-array", + "title": "sixteen-layer terrain splat through four array textures", + "category": "world", + "status": "implemented", + "scene": "conformance/scenes/shared/terrain-splat-array.js", + "browserRecipe": "webgpu", + "requiresHardwareAdapter": true, + "tolerance": { + "pixelMismatchRatio": 0.08, + "perceptualDeltaE": 10.0 + }, + "desktopGate": true, + "required": true + }, { "id": "100-world-field-hash", "title": "procedural world field hash parity", diff --git a/packages/runtime-native/conformance/scenes/shared/terrain-splat-array.js b/packages/runtime-native/conformance/scenes/shared/terrain-splat-array.js new file mode 100644 index 0000000000..8696d9fe95 --- /dev/null +++ b/packages/runtime-native/conformance/scenes/shared/terrain-splat-array.js @@ -0,0 +1,150 @@ +import { loadTerrainSplat } from "../../../../core/src/world-terrain-splat.ts"; +import { assertCondition, startVisualScene, THREE } from "./scene-support.js"; + +/** + * A sixteen-layer splat package whose 48 maps are uncompressed and the same size — the shape + * Android and iOS get, having no KTX2 transcoder. `loadTerrainSplat` stacks each set into one + * array texture by GPU copy, so the surface binds four sampled textures instead of forty-nine; + * this row is what says the native host agrees with the browser. + */ + +const LAYERS = 16; +const PLANE = 4; // mask texels a side; each layer owns one, and cell 0 is left to the base +const PLANES = 4; +const MAP = 8; // map texels a side +const HALF = 4; // the ground spans HALF metres either side of the origin + +/** Every fixture byte is a pure function of its cell: both lanes must derive the same one. */ +function mapRgb(layer, kind, x, y) { + if (kind === "nrm") return [128 + (layer % 5) * 24, 128 + (((layer >> 1) % 5) * 20) % 127, 255]; + if (kind === "orm") return [255, 40 + ((layer * 17) % 200), (layer % 3) * 40]; + const step = 2 + (layer % 4); + const on = (Math.floor(x / step) + Math.floor(y / step) + layer) % 2 === 0; + return on ? [230, 90 + (layer % 6) * 25, 60] : [50, 40 + (layer % 3) * 20, 90 + (layer % 5) * 20]; +} + +/** One map as the uncompressed RGBA8 texture an uncompressed layer ships. */ +function layerTexture(layer, kind, srgb) { + const data = new Uint8Array(MAP * MAP * 4); + for (let y = 0; y < MAP; y += 1) + for (let x = 0; x < MAP; x += 1) { + const rgb = mapRgb(layer, kind, x, y); + const at = (y * MAP + x) * 4; + data[at] = rgb[0]; + data[at + 1] = rgb[1]; + data[at + 2] = rgb[2]; + data[at + 3] = 255; + } + const texture = new THREE.DataTexture(data, MAP, MAP, THREE.RGBAFormat); + texture.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace; + texture.flipY = false; + texture.needsUpdate = true; + return texture; +} + +/** The mask planes: layer `slot` is lit in cell `slot + 1` of a 4x4 grid, and nowhere else. */ +function splatBytes() { + const bytes = new Uint8Array(PLANES * PLANE * PLANE * 4); + for (let slot = 0; slot < LAYERS - 1; slot += 1) { + const cell = slot + 1; + bytes[(Math.floor(cell / PLANE) * PLANE + (cell % PLANE)) * 4 + (slot % 4)] = 255; + } + return bytes; +} + +function layersTable() { + const masks = {}; + const layers = []; + for (let slot = 0; slot < LAYERS - 1; slot += 1) { + masks[`m${slot}`] = [Math.floor(slot / 4), "rgb"]; + layers.push({ + channel: ["r", "g", "b", "a"][slot % 4], + hi: 0.75, + id: `layer${slot}`, + lo: 0.25, + mask: `m${slot}`, + normal: true, + orm: true, + tile: 2, + tint: [0.4 + (slot % 5) * 0.14, 0.95 - (slot % 3) * 0.22, 0.35 + ((slot >> 1) % 5) * 0.13], + }); + } + return { + base: { id: "base", normal: true, orm: true, tile: 2, tint: [0.85, 0.85, 0.88] }, + breakup: { push: 0.06, scale: 0.35 }, + layers, + macro: { max: 1.1, min: 0.85, scale: 0.12 }, + splat: { masks, planes: PLANES, size: PLANE }, + textures: "layers", + }; +} + +/** world.json, the table and the mask bytes, served the way a game's own package is. */ +function packageAssets() { + const files = new Map([ + [ + "world.json", + JSON.stringify({ + extent: { minX: -HALF, minZ: -HALF, sizeX: HALF * 2, sizeZ: HALF * 2 }, + terrain: { layers: { splat: "layers/splat.rgba8", table: "layers/table.json" } }, + version: 1, + }), + ], + ["layers/table.json", JSON.stringify(layersTable())], + ["layers/splat.rgba8", splatBytes()], + ]); + return { + resolve: async (path) => { + const bytes = files.get(path); + assertCondition(bytes !== undefined, `terrain-splat-array: no fixture at '${path}'`); + return [URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }))]; + }, + texture: async (path, options = {}) => { + const match = /^layers\/(?:base|layer(\d+))_(diff|nrm|orm)\.jpg$/u.exec(path); + assertCondition(match !== null, `terrain-splat-array: unexpected map '${path}'`); + const texture = layerTexture( + match[1] === undefined ? 0 : Number(match[1]), + match[2], + options.data !== true, + ); + texture.name = path; + return texture; + }, + }; +} + +export async function startScene(canvas, dimensions) { + return startVisualScene( + canvas, + dimensions, + "terrain-splat-array", + async ({ renderer, scene }) => { + const material = await loadTerrainSplat({ + assets: packageAssets(), + renderer: { raw: renderer }, + url: "world.json", + }); + const marker = String(material.userData.TN_TERRAIN_SPLAT); + const number = (key) => Number(new RegExp(`${key}=(\\d+)`, "u").exec(marker)?.[1] ?? -1); + assertCondition(number("layers") === LAYERS, `sixteen layers expected, got ${marker}`); + assertCondition(number("stacked") === 3, `three stacked sets expected, got ${marker}`); + assertCondition(number("samplers") === 4, `four sampled textures expected, got ${marker}`); + + const ground = new THREE.Mesh(new THREE.PlaneGeometry(HALF * 2, HALF * 2), material); + ground.rotation.x = -Math.PI / 2; + const sun = new THREE.DirectionalLight(0xffffff, 2.6); + sun.position.set(2, 6, 3); + scene.add(ground, sun, new THREE.AmbientLight(0xa8c0ff, 0.6)); + return { detail: { layers: LAYERS, samplers: 4, stacked: 3 }, ground, material }; + }, + { + background: 0x0d1520, + camera: (size) => { + const camera = new THREE.PerspectiveCamera(50, size.width / size.height, 0.1, 100); + camera.position.set(0, 5.5, 6.5); + camera.lookAt(0, 0, -0.5); + return camera; + }, + }, + ); +} \ No newline at end of file From 19f7fdda97aeeb8924cdb3f37304633616cf7620 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 4 Oct 2026 14:07:46 -0700 Subject: [PATCH 6/6] fix: reconcile merged terrain fixture Three patch hash --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74834b9a1d..7abaf1ed78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,7 +587,7 @@ importers: version: link:../../packages/playtest three: specifier: 'catalog:' - version: 0.185.1(patch_hash=083710b3f650299016af6b9a0463e600517a5686f7f1713dead2b0f7ab9218d0) + version: 0.185.1(patch_hash=c2e051df4c469d033b151cb9da5ce5112c90801aaaa984d6bfde0e09d80c9a66) devDependencies: '@threenative/runtime-native': specifier: workspace:*