diff --git a/apps/game/public/assets/PROVENANCE.md b/apps/game/public/assets/PROVENANCE.md new file mode 100644 index 0000000..1e69da1 --- /dev/null +++ b/apps/game/public/assets/PROVENANCE.md @@ -0,0 +1,72 @@ +# Asset provenance + +Every file in this directory is **generated**, not authored by hand and not +obtained from anywhere. CLAUDE.md requires provenance for any public asset; for +this set the provenance is a commit, a script and a seed. + +## How to rebuild + +```sh +pnpm assets:build # models + textures +pnpm assets:build --previews # ...and render a preview PNG per model +``` + +Requires Blender 4.5+ on `PATH` or in `$BLENDER`. Blender's bundled Python +supplies numpy, so there is no pip dependency. `manifest.json` records the +Blender version and commit each build came from. + +## Sources + +| Output | Generator | +| --------------------------------------------------------------------------------- | -------------------------------------------- | +| `models/container.glb` | `tools/art/blender/container.py` | +| `models/tank.glb`, `deck`, `pipe_rack`, `wall`, `hardpoint`, `stair`, `lamp_mast` | `tools/art/blender/yard.py` | +| `models/character.glb` | `tools/art/blender/character.py` | +| `models/carbine.glb` | `tools/art/blender/weapon.py` | +| `textures/*_{albedo,normal,orm}.webp` | `tools/art/textures/generate.py` | +| `textures/env_sky.webp` | `tools/art/textures/generate.py` (`env_sky`) | + +Shared modelling helpers live in `tools/art/blender/_lib.py`; the orchestrator +is `tools/art/build-assets.mjs`. + +## Licence + +Original work, © NIGHTCELL 7. No third-party assets, no photographic sources, +no scanned or traced material, and nothing derived from another game +(CLAUDE.md). Nothing here was produced by a generative image or 3D model. + +## Conventions + +These are contracts, not style preferences, and `apps/game/src/assets.test.ts` +enforces them: + +- **One Blender metre is one game metre.** Props are sized against the collision + volumes in `packages/multiplayer-sim/src/map.ts`, which is the authority. +- **Models ship no embedded textures.** A GLB carries geometry, UVs and a + _named_ material slot; `apps/game/src/assets.ts` binds the shared PBR maps to + that name. Embedding would ship the same steel texture once per model and + push the shell past its 15 MB download budget (PRD §30). +- **UVs are in world units** at a fixed texel density (one tile per 4 m), so + adjacent props always agree on texture scale. +- **`COL_` prefixes collision proxies**, exported so each GLB is + self-describing. The engine collides against the server's map, not against + art, so these are not rendered. +- **`SOCKET_` prefixes attachment points.** Every weapon has `SOCKET_MUZZLE`. +- **Deterministic.** All randomness is seeded. Rebuilding on the same commit + produces the same bytes, so a dirty `git status` after a rebuild means a + generator picked up an unseeded source of randomness. + +## Textures + +Seven materials — concrete, steel, rust, paint_red, paint_cyan, grating, +rubber — each with albedo, tangent-space normal, and an ORM pack +(R = occlusion, G = roughness, B = metallic), at 1024², encoded as WebP q88. + +`env_sky.webp` is an equirectangular environment map for image-based lighting. +It is **required**, not decorative: a physically-based metal is lit almost +entirely by what it reflects, and with no `scene.environmentTexture` every +steel, rust and grating surface in the yard renders pure black. + +Lossy WebP is deliberate. The same set encoded losslessly is 12.5 MB against +1.9 MB here — it would consume nearly the whole shell budget and force smaller, +worse textures. diff --git a/apps/game/public/assets/manifest.json b/apps/game/public/assets/manifest.json new file mode 100644 index 0000000..a19f657 --- /dev/null +++ b/apps/game/public/assets/manifest.json @@ -0,0 +1,50 @@ +{ + "generator": "tools/art/build-assets.mjs", + "blender": "Blender 4.5.12 LTS", + "commit": "bf4d13f789386baa436f5e17e69be82763b3e142", + "license": "Original work, © NIGHTCELL 7. No third-party assets.", + "source": "Generated procedurally from the scripts in tools/art. No asset is downloaded, photographed, traced, or derived from another game.", + "textureSize": 1024, + "webpQuality": 88, + "models": [ + "carbine.glb", + "character.glb", + "container.glb", + "deck.glb", + "hardpoint.glb", + "lamp_mast.glb", + "pipe_rack.glb", + "stair.glb", + "tank.glb", + "wall.glb" + ], + "textures": [ + "concrete_albedo.webp", + "concrete_normal.webp", + "concrete_orm.webp", + "env_sky.webp", + "grating_albedo.webp", + "grating_normal.webp", + "grating_orm.webp", + "paint_cyan_albedo.webp", + "paint_cyan_normal.webp", + "paint_cyan_orm.webp", + "paint_red_albedo.webp", + "paint_red_normal.webp", + "paint_red_orm.webp", + "rubber_albedo.webp", + "rubber_normal.webp", + "rubber_orm.webp", + "rust_albedo.webp", + "rust_normal.webp", + "rust_orm.webp", + "steel_albedo.webp", + "steel_normal.webp", + "steel_orm.webp" + ], + "bytes": { + "models": 1135736, + "textures": 1971598, + "total": 3107334 + } +} diff --git a/apps/game/public/assets/models/carbine.glb b/apps/game/public/assets/models/carbine.glb new file mode 100644 index 0000000..20288e9 Binary files /dev/null and b/apps/game/public/assets/models/carbine.glb differ diff --git a/apps/game/public/assets/models/character.glb b/apps/game/public/assets/models/character.glb new file mode 100644 index 0000000..3e53179 Binary files /dev/null and b/apps/game/public/assets/models/character.glb differ diff --git a/apps/game/public/assets/models/container.glb b/apps/game/public/assets/models/container.glb new file mode 100644 index 0000000..bb17e5d Binary files /dev/null and b/apps/game/public/assets/models/container.glb differ diff --git a/apps/game/public/assets/models/deck.glb b/apps/game/public/assets/models/deck.glb new file mode 100644 index 0000000..f5f9cba Binary files /dev/null and b/apps/game/public/assets/models/deck.glb differ diff --git a/apps/game/public/assets/models/hardpoint.glb b/apps/game/public/assets/models/hardpoint.glb new file mode 100644 index 0000000..1b438be Binary files /dev/null and b/apps/game/public/assets/models/hardpoint.glb differ diff --git a/apps/game/public/assets/models/lamp_mast.glb b/apps/game/public/assets/models/lamp_mast.glb new file mode 100644 index 0000000..b401d70 Binary files /dev/null and b/apps/game/public/assets/models/lamp_mast.glb differ diff --git a/apps/game/public/assets/models/pipe_rack.glb b/apps/game/public/assets/models/pipe_rack.glb new file mode 100644 index 0000000..2220363 Binary files /dev/null and b/apps/game/public/assets/models/pipe_rack.glb differ diff --git a/apps/game/public/assets/models/stair.glb b/apps/game/public/assets/models/stair.glb new file mode 100644 index 0000000..f1113dc Binary files /dev/null and b/apps/game/public/assets/models/stair.glb differ diff --git a/apps/game/public/assets/models/tank.glb b/apps/game/public/assets/models/tank.glb new file mode 100644 index 0000000..1184f0f Binary files /dev/null and b/apps/game/public/assets/models/tank.glb differ diff --git a/apps/game/public/assets/models/wall.glb b/apps/game/public/assets/models/wall.glb new file mode 100644 index 0000000..c5e9038 Binary files /dev/null and b/apps/game/public/assets/models/wall.glb differ diff --git a/apps/game/public/assets/textures/concrete_albedo.webp b/apps/game/public/assets/textures/concrete_albedo.webp new file mode 100644 index 0000000..f95d929 Binary files /dev/null and b/apps/game/public/assets/textures/concrete_albedo.webp differ diff --git a/apps/game/public/assets/textures/concrete_normal.webp b/apps/game/public/assets/textures/concrete_normal.webp new file mode 100644 index 0000000..43813ff Binary files /dev/null and b/apps/game/public/assets/textures/concrete_normal.webp differ diff --git a/apps/game/public/assets/textures/concrete_orm.webp b/apps/game/public/assets/textures/concrete_orm.webp new file mode 100644 index 0000000..59d36a6 Binary files /dev/null and b/apps/game/public/assets/textures/concrete_orm.webp differ diff --git a/apps/game/public/assets/textures/env_sky.webp b/apps/game/public/assets/textures/env_sky.webp new file mode 100644 index 0000000..3f5e079 Binary files /dev/null and b/apps/game/public/assets/textures/env_sky.webp differ diff --git a/apps/game/public/assets/textures/grating_albedo.webp b/apps/game/public/assets/textures/grating_albedo.webp new file mode 100644 index 0000000..9592402 Binary files /dev/null and b/apps/game/public/assets/textures/grating_albedo.webp differ diff --git a/apps/game/public/assets/textures/grating_normal.webp b/apps/game/public/assets/textures/grating_normal.webp new file mode 100644 index 0000000..2b7ca2f Binary files /dev/null and b/apps/game/public/assets/textures/grating_normal.webp differ diff --git a/apps/game/public/assets/textures/grating_orm.webp b/apps/game/public/assets/textures/grating_orm.webp new file mode 100644 index 0000000..0b9d1d6 Binary files /dev/null and b/apps/game/public/assets/textures/grating_orm.webp differ diff --git a/apps/game/public/assets/textures/paint_cyan_albedo.webp b/apps/game/public/assets/textures/paint_cyan_albedo.webp new file mode 100644 index 0000000..75e32c4 Binary files /dev/null and b/apps/game/public/assets/textures/paint_cyan_albedo.webp differ diff --git a/apps/game/public/assets/textures/paint_cyan_normal.webp b/apps/game/public/assets/textures/paint_cyan_normal.webp new file mode 100644 index 0000000..58ee188 Binary files /dev/null and b/apps/game/public/assets/textures/paint_cyan_normal.webp differ diff --git a/apps/game/public/assets/textures/paint_cyan_orm.webp b/apps/game/public/assets/textures/paint_cyan_orm.webp new file mode 100644 index 0000000..3ccc926 Binary files /dev/null and b/apps/game/public/assets/textures/paint_cyan_orm.webp differ diff --git a/apps/game/public/assets/textures/paint_red_albedo.webp b/apps/game/public/assets/textures/paint_red_albedo.webp new file mode 100644 index 0000000..7db8204 Binary files /dev/null and b/apps/game/public/assets/textures/paint_red_albedo.webp differ diff --git a/apps/game/public/assets/textures/paint_red_normal.webp b/apps/game/public/assets/textures/paint_red_normal.webp new file mode 100644 index 0000000..f389044 Binary files /dev/null and b/apps/game/public/assets/textures/paint_red_normal.webp differ diff --git a/apps/game/public/assets/textures/paint_red_orm.webp b/apps/game/public/assets/textures/paint_red_orm.webp new file mode 100644 index 0000000..3bd4d8c Binary files /dev/null and b/apps/game/public/assets/textures/paint_red_orm.webp differ diff --git a/apps/game/public/assets/textures/rubber_albedo.webp b/apps/game/public/assets/textures/rubber_albedo.webp new file mode 100644 index 0000000..352b656 Binary files /dev/null and b/apps/game/public/assets/textures/rubber_albedo.webp differ diff --git a/apps/game/public/assets/textures/rubber_normal.webp b/apps/game/public/assets/textures/rubber_normal.webp new file mode 100644 index 0000000..88c37ea Binary files /dev/null and b/apps/game/public/assets/textures/rubber_normal.webp differ diff --git a/apps/game/public/assets/textures/rubber_orm.webp b/apps/game/public/assets/textures/rubber_orm.webp new file mode 100644 index 0000000..ce26ddd Binary files /dev/null and b/apps/game/public/assets/textures/rubber_orm.webp differ diff --git a/apps/game/public/assets/textures/rust_albedo.webp b/apps/game/public/assets/textures/rust_albedo.webp new file mode 100644 index 0000000..9fa0035 Binary files /dev/null and b/apps/game/public/assets/textures/rust_albedo.webp differ diff --git a/apps/game/public/assets/textures/rust_normal.webp b/apps/game/public/assets/textures/rust_normal.webp new file mode 100644 index 0000000..fe25e10 Binary files /dev/null and b/apps/game/public/assets/textures/rust_normal.webp differ diff --git a/apps/game/public/assets/textures/rust_orm.webp b/apps/game/public/assets/textures/rust_orm.webp new file mode 100644 index 0000000..1e903ee Binary files /dev/null and b/apps/game/public/assets/textures/rust_orm.webp differ diff --git a/apps/game/public/assets/textures/steel_albedo.webp b/apps/game/public/assets/textures/steel_albedo.webp new file mode 100644 index 0000000..97d3a6d Binary files /dev/null and b/apps/game/public/assets/textures/steel_albedo.webp differ diff --git a/apps/game/public/assets/textures/steel_normal.webp b/apps/game/public/assets/textures/steel_normal.webp new file mode 100644 index 0000000..c8867a1 Binary files /dev/null and b/apps/game/public/assets/textures/steel_normal.webp differ diff --git a/apps/game/public/assets/textures/steel_orm.webp b/apps/game/public/assets/textures/steel_orm.webp new file mode 100644 index 0000000..6642df6 Binary files /dev/null and b/apps/game/public/assets/textures/steel_orm.webp differ diff --git a/apps/game/src/assets.test.ts b/apps/game/src/assets.test.ts new file mode 100644 index 0000000..1686d7b --- /dev/null +++ b/apps/game/src/assets.test.ts @@ -0,0 +1,145 @@ +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { MATERIALS, MODELS } from "./assets"; + +/** + * Guards for the generated art set. + * + * These check the *built* assets in `apps/game/public/assets`, not the + * generator scripts, because the failure modes that matter all happen at the + * boundary between Blender and the engine: + * + * - a material slot renamed in `_lib.py` un-skins every mesh that used it, + * and the game still loads — just untextured; + * - a model dropped from the build leaves a hole in the yard; + * - textures creep upward in size until the shell blows its download budget. + * + * None of those throw at runtime, so nothing else would catch them. + */ + +const ASSETS = join(__dirname, "../public/assets"); +const MODELS_DIR = join(ASSETS, "models"); +const TEXTURES_DIR = join(ASSETS, "textures"); + +/** + * Total bytes the generated art may occupy, uncompressed. + * + * The hard limit is the 15 MB shell download budget (PRD §30, + * `DOWNLOAD_BUDGET_BYTES`), which also has to fit the engine, the code and the + * fonts. 6 MB leaves room for all of that and is roughly double what the + * current set uses, so it catches a runaway without failing on every addition. + */ +const ASSET_BUDGET_BYTES = 6 * 1024 * 1024; + +/** Read the JSON chunk out of a GLB container. */ +function glbJson(file: string): { + nodes?: { name?: string }[]; + materials?: { name?: string }[]; + meshes?: { name?: string }[]; +} { + const buffer = readFileSync(file); + expect(buffer.toString("utf8", 0, 4), `${file} is not a GLB`).toBe("glTF"); + + const chunkLength = buffer.readUInt32LE(12); + const chunkType = buffer.readUInt32LE(16); + // 0x4E4F534A === "JSON" + expect(chunkType, `${file} first chunk is not JSON`).toBe(0x4e4f534a); + + return JSON.parse(buffer.toString("utf8", 20, 20 + chunkLength)); +} + +function names(entries: { name?: string }[] | undefined): string[] { + return (entries ?? []).map((e) => e.name ?? ""); +} + +describe("generated models", () => { + it("every model the engine loads exists on disk", () => { + for (const model of MODELS) { + const file = join(MODELS_DIR, `${model}.glb`); + expect(() => statSync(file), `missing ${model}.glb — run pnpm assets:build`).not.toThrow(); + } + }); + + it("only uses material slots the engine can bind", () => { + // A slot the engine does not know about is not an error at load time: the + // mesh simply keeps its untextured placeholder material and renders flat. + const known = new Set([...MATERIALS, "lamp_glass"]); + + for (const model of MODELS) { + for (const material of names(glbJson(join(MODELS_DIR, `${model}.glb`)).materials)) { + expect(known, `${model}.glb uses unknown material slot "${material}"`).toContain(material); + } + } + }); + + it("ships a collision proxy with every prop", () => { + // The carbine is exempt: it is a viewmodel held at the camera and a world + // model on a character's back. It never collides with anything, so a + // COL_ hull on it would be geometry that exists only to satisfy a rule. + const NO_COLLIDER = new Set(["carbine"]); + + for (const model of MODELS) { + if (NO_COLLIDER.has(model)) continue; + const meshes = names(glbJson(join(MODELS_DIR, `${model}.glb`)).meshes); + expect( + meshes.some((n) => n.startsWith("COL_")), + `${model}.glb has no COL_ collision proxy (CLAUDE.md)`, + ).toBe(true); + } + }); + + it("gives every weapon a SOCKET_MUZZLE", () => { + // CLAUDE.md: "Every weapon has SOCKET_MUZZLE." The engine spawns muzzle + // flash and tracer origins there. + const nodes = names(glbJson(join(MODELS_DIR, "carbine.glb")).nodes); + expect(nodes.some((n) => n.startsWith("SOCKET_MUZZLE"))).toBe(true); + }); + + it("keeps the character's weapon and head sockets", () => { + const nodes = names(glbJson(join(MODELS_DIR, "character.glb")).nodes); + expect(nodes.some((n) => n.startsWith("SOCKET_WEAPON"))).toBe(true); + expect(nodes.some((n) => n.startsWith("SOCKET_HEAD"))).toBe(true); + }); +}); + +describe("generated textures", () => { + it("ships a full PBR set for every material", () => { + for (const material of MATERIALS) { + for (const map of ["albedo", "normal", "orm"]) { + const file = join(TEXTURES_DIR, `${material}_${map}.webp`); + expect(() => statSync(file), `missing ${material}_${map}.webp`).not.toThrow(); + } + } + }); + + it("ships the IBL environment", () => { + // Without this every metal in the yard renders black, and nothing throws. + expect(() => statSync(join(TEXTURES_DIR, "env_sky.webp"))).not.toThrow(); + }); +}); + +describe("download budget", () => { + it("stays well inside the shell budget", () => { + const manifest = JSON.parse(readFileSync(join(ASSETS, "manifest.json"), "utf8")) as { + bytes: { total: number }; + }; + + expect( + manifest.bytes.total, + `generated assets are ${(manifest.bytes.total / 1048576).toFixed(2)} MB, ` + + `over the ${(ASSET_BUDGET_BYTES / 1048576).toFixed(0)} MB guard`, + ).toBeLessThan(ASSET_BUDGET_BYTES); + }); + + it("manifest lists exactly what is on disk", () => { + const manifest = JSON.parse(readFileSync(join(ASSETS, "manifest.json"), "utf8")) as { + models: string[]; + textures: string[]; + }; + for (const model of MODELS) { + expect(manifest.models, `manifest missing ${model}`).toContain(`${model}.glb`); + } + expect(manifest.textures).toContain("env_sky.webp"); + }); +}); diff --git a/apps/game/src/assets.ts b/apps/game/src/assets.ts new file mode 100644 index 0000000..193ef3e --- /dev/null +++ b/apps/game/src/assets.ts @@ -0,0 +1,320 @@ +import type { AssetContainer, Mesh, Scene } from "@babylonjs/core"; +import { + Color3, + EquiRectangularCubeTexture, + LoadAssetContainerAsync, + PBRMaterial, + StandardMaterial, + Texture, + TransformNode, + Vector3, +} from "@babylonjs/core"; +import "@babylonjs/loaders/glTF"; + +/** + * Runtime asset loading for the generated art set. + * + * Everything here is produced by `tools/art/build-assets.mjs` — see + * `apps/game/public/assets/manifest.json` for provenance. Two conventions from + * the build side are load-bearing: + * + * 1. **Models carry no textures.** A GLB ships geometry, UVs and a *named* + * material slot; this file binds the real PBR maps to that name. Embedding + * images per model would ship the same steel texture ten times and blow the + * 15 MB shell budget (PRD §30), which would in turn force smaller, worse + * textures. + * 2. **UVs are in world units.** The generators unwrap at a fixed texel + * density (one tile per 4 m), so tiling needs no per-object uScale and two + * adjacent props always agree on texture scale. + */ + +const ASSET_BASE = `${import.meta.env.BASE_URL}assets/`; + +/** Material slot names shared with `tools/art/blender/_lib.py`. */ +export const MATERIALS = [ + "concrete", + "steel", + "rust", + "paint_red", + "paint_cyan", + "grating", + "rubber", +] as const; + +export type MaterialName = (typeof MATERIALS)[number] | "lamp_glass"; + +/** Models built by the asset pipeline. */ +export const MODELS = [ + "container", + "tank", + "deck", + "pipe_rack", + "wall", + "hardpoint", + "stair", + "lamp_mast", + "character", + "carbine", +] as const; + +export type ModelName = (typeof MODELS)[number]; + +export interface AssetSet { + readonly materials: ReadonlyMap; + readonly models: ReadonlyMap; +} + +// ---------------------------------------------------------------- materials + +function loadTexture(scene: Scene, file: string, srgb: boolean): Texture { + const texture = new Texture( + `${ASSET_BASE}textures/${file}`, + scene, + // Mipmaps on, and NOT inverted in Y: the generator writes conventional + // top-left-origin images. + false, + false, + Texture.TRILINEAR_SAMPLINGMODE, + ); + texture.wrapU = Texture.WRAP_ADDRESSMODE; + texture.wrapV = Texture.WRAP_ADDRESSMODE; + // Only base colour is authored in sRGB. Normal and ORM are data, and + // gamma-decoding them would bend every roughness and normal value. + texture.gammaSpace = srgb; + // The yard is full of grazing angles down long lanes; without anisotropy the + // ground turns to mush a few metres out. + texture.anisotropicFilteringLevel = 8; + return texture; +} + +/** + * Build the shared PBR materials. + * + * One material per name, reused by every mesh that asks for it, so the whole + * yard draws from a handful of texture bindings. + */ +export function createMaterials(scene: Scene): Map { + const materials = new Map(); + + for (const name of MATERIALS) { + const material = new PBRMaterial(name, scene); + material.albedoTexture = loadTexture(scene, `${name}_albedo.webp`, true); + material.bumpTexture = loadTexture(scene, `${name}_normal.webp`, false); + material.metallicTexture = loadTexture(scene, `${name}_orm.webp`, false); + + // ORM packing, matching the generator: R = occlusion, G = roughness, + // B = metallic. + material.useAmbientOcclusionFromMetallicTextureRed = true; + material.useRoughnessFromMetallicTextureGreen = true; + material.useMetallnessFromMetallicTextureBlue = true; + // Factors stay at 1 so the texture values are used unmodified — Babylon + // multiplies factor by texture. + material.metallic = 1; + material.roughness = 1; + + // The meshes ship without tangents to keep them small; Babylon then builds + // a TBN from screen-space derivatives, which needs this flag. + material.useParallax = false; + material.forceIrradianceInFragment = true; + // The yard runs eleven lamp masts plus two spawn markers and three + // directionals. Babylon's default cap is four lights per mesh and it drops + // the rest *silently*, which showed up as lamp pools vanishing depending + // on where you stood. Six is the compromise: it covers any realistic + // cluster without paying for sixteen light terms in the shader. + material.maxSimultaneousLights = 6; + + materials.set(name, material); + } + + // Lamp lenses are the one unlit surface: they are a light source, and + // shading them would make the fitting darker than the pool of light it casts. + const lens = new StandardMaterial("lamp_glass", scene); + lens.disableLighting = true; + lens.emissiveColor = new Color3(1.0, 0.71, 0.36); + materials.set("lamp_glass", lens); + + return materials; +} + +/** + * Install the image-based lighting environment. + * + * Mandatory, not an enhancement. A physically-based metal is lit almost + * entirely by what it reflects, so with no environment texture every steel, + * rust and grating surface in the yard renders black — which is precisely how + * the yard looked the first time the models went in. + * + * The map is a plain equirectangular image rather than a prefiltered `.env`. + * Prefiltering would give more accurate roughness-dependent blur, but it is a + * separate offline step and a much larger file; at this art direction — dark, + * hazy, low-gloss — the difference is not visible, and Babylon still generates + * mip levels to approximate rough reflections. + */ +export function createEnvironment(scene: Scene): EquiRectangularCubeTexture { + const environment = new EquiRectangularCubeTexture( + `${ASSET_BASE}textures/env_sky.webp`, + scene, + 256, + ); + scene.environmentTexture = environment; + // The yard is lit by its own lamps and the dawn; the environment supplies + // reflection and a little fill, and overpowering it flattens the scene. + scene.environmentIntensity = 1.4; + return environment; +} + +/** + * A variant of one of the shared materials with its own texture tiling. + * + * Generated meshes carry world-unit UVs and need no scaling, but Babylon + * primitives (the ground slab) are unwrapped 0..1 across the whole face, so + * they need the tiling applied on the texture itself. Babylon caches GPU + * textures by URL, so these extra `Texture` wrappers cost almost no memory. + */ +export function createTiledMaterial( + scene: Scene, + base: (typeof MATERIALS)[number], + uScale: number, + vScale: number, +): PBRMaterial { + const material = new PBRMaterial(`${base}_tiled`, scene); + + for (const [slot, file, srgb] of [ + ["albedoTexture", `${base}_albedo.webp`, true], + ["bumpTexture", `${base}_normal.webp`, false], + ["metallicTexture", `${base}_orm.webp`, false], + ] as const) { + const texture = loadTexture(scene, file, srgb); + texture.uScale = uScale; + texture.vScale = vScale; + (material as unknown as Record)[slot] = texture; + } + + material.useAmbientOcclusionFromMetallicTextureRed = true; + material.useRoughnessFromMetallicTextureGreen = true; + material.useMetallnessFromMetallicTextureBlue = true; + material.metallic = 1; + material.roughness = 1; + + return material; +} + +// ------------------------------------------------------------------- models + +/** + * Load one GLB into an AssetContainer, bind shared materials, and drop the + * collision proxies. + * + * `COL_` meshes exist so the GLB is self-describing (CLAUDE.md) but the engine + * collides against the server's map, not against art. Rendering them would + * double every prop's triangle count and z-fight with the visible shell. + */ +async function loadModel( + scene: Scene, + name: ModelName, + materials: ReadonlyMap, +): Promise { + const container = await LoadAssetContainerAsync(`${ASSET_BASE}models/${name}.glb`, scene); + + for (const mesh of [...container.meshes]) { + if (mesh.name.startsWith("COL_")) { + container.meshes.splice(container.meshes.indexOf(mesh), 1); + mesh.dispose(); + continue; + } + + const slot = mesh.material?.name; + const shared = slot ? materials.get(slot) : undefined; + if (shared) mesh.material = shared; + + mesh.receiveShadows = true; + mesh.isPickable = false; + // Static props never move once placed; skipping the frustum test on a + // hundred instances is measurably cheaper than the culling it saves. + mesh.alwaysSelectAsActiveMesh = false; + } + + // Materials that came in from the GLB are now unreferenced. + for (const material of [...container.materials]) { + if (!materials.has(material.name)) { + container.materials.splice(container.materials.indexOf(material), 1); + material.dispose(); + } + } + + return container; +} + +export async function loadAssets(scene: Scene, only?: readonly ModelName[]): Promise { + // Before the materials, so nothing can be created against an empty + // environment and render black. + createEnvironment(scene); + const materials = createMaterials(scene); + const wanted = only ?? MODELS; + + const loaded = await Promise.all( + wanted.map(async (name) => [name, await loadModel(scene, name, materials)] as const), + ); + + return { materials, models: new Map(loaded) }; +} + +// ---------------------------------------------------------------- placement + +export interface Placement { + readonly position: Vector3; + /** Yaw in radians. */ + readonly rotationY?: number; + readonly scaling?: Vector3; +} + +/** + * Instantiate `container` once per placement. + * + * `instantiateModelsToScene` produces real Babylon instances for repeated + * meshes, so twenty wall panels are one draw call rather than twenty, and it + * reproduces the glTF node hierarchy — which matters because the loader adds a + * `__root__` node to convert glTF's right-handed space. Building the transform + * by hand instead means getting that conversion right on every prop. + */ +export function placeAll( + container: AssetContainer, + name: string, + placements: readonly Placement[], +): TransformNode[] { + const roots: TransformNode[] = []; + + placements.forEach((placement, index) => { + const entry = container.instantiateModelsToScene( + (source) => `${name}_${index}_${source}`, + false, + { doNotInstantiate: false }, + ); + + // `rootNodes` is typed as the base `Node`; only transform nodes can be + // positioned, and in practice that is all a glTF import produces at the + // root. + for (const node of entry.rootNodes) { + if (!(node instanceof TransformNode)) continue; + node.position = placement.position.clone(); + if (placement.rotationY !== undefined) { + node.rotation = new Vector3(0, placement.rotationY, 0); + } + if (placement.scaling) node.scaling = placement.scaling.clone(); + roots.push(node); + } + }); + + return roots; +} + +/** Every mesh under a set of instantiated roots, for shadow registration. */ +export function meshesUnder(roots: readonly TransformNode[]): Mesh[] { + const out: Mesh[] = []; + for (const root of roots) { + for (const child of root.getChildMeshes()) { + out.push(child as Mesh); + } + } + return out; +} diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts index 7f50c42..0375af8 100644 --- a/apps/game/src/main.ts +++ b/apps/game/src/main.ts @@ -5,6 +5,7 @@ import { modeLabel, renderGate } from "./gate"; import { createHud, renderFault } from "./hud"; import { requestedVantage } from "./photo"; import { PlayerController } from "./player"; +import { Viewmodel } from "./viewmodel"; import { createRenderer, DynamicResolution } from "./renderer"; import { buildWorld } from "./world"; import "./style.css"; @@ -67,7 +68,7 @@ async function boot(): Promise { // Input is owned by PlayerController, which runs the shared authoritative // simulation. Attaching Babylon's own controls here would fight it. - buildWorld(scene, engine, camera, ARDAVAN_YARD); + const world = await buildWorld(scene, engine, camera, ARDAVAN_YARD); // Photo mode: park the camera at a named vantage, leave the UI layer empty, // and skip the controller entirely. Used to regenerate marketing captures @@ -92,6 +93,14 @@ async function boot(): Promise { return; } + // The rifle. PRD §40 wants "one enemy and one rifle" to feel good before + // anything else; until now the player's hands were empty. + // + // Created after the photo-mode return on purpose: the vantages in photo.ts + // are environment showcases for the marketing site, and a weapon filling the + // lower third of frame hides the yard they exist to show. + const viewmodel = new Viewmodel(scene, camera, world.assets); + const player = new PlayerController(scene, camera, canvas, ARDAVAN_YARD, spawn); const hud = createHud(ui, { @@ -112,7 +121,9 @@ async function boot(): Promise { // Movement only advances while the pointer is locked; otherwise the start // gate is up and the yard should sit still behind it. if (player.isLocked) player.update(deltaMs); - hud.update(player.status(), engine.getFps()); + const status = player.status(); + viewmodel.update(deltaMs, status.speed, camera.rotation.y, camera.rotation.x); + hud.update(status, engine.getFps()); scene.render(); }); diff --git a/apps/game/src/viewmodel.ts b/apps/game/src/viewmodel.ts new file mode 100644 index 0000000..4784562 --- /dev/null +++ b/apps/game/src/viewmodel.ts @@ -0,0 +1,169 @@ +import type { Scene, TransformNode } from "@babylonjs/core"; +import { Color3, HemisphericLight, Vector3, type Camera, type Mesh } from "@babylonjs/core"; +import { placeAll, type AssetSet } from "./assets"; + +/** + * First-person weapon viewmodel. + * + * The carbine occupies the lower third of the screen for every frame of every + * match, so it is the single most-viewed object in the game — and until now the + * player's hands were empty, which is a large part of why the build read as + * unfinished. PRD §40: "a grey room, one enemy, and one rifle must already feel + * good". + * + * Two things here are not cosmetic: + * + * * **Its own rendering group.** The viewmodel sits centimetres from the near + * plane. Rendered with the world it clips through every wall the player + * stands near, so it is drawn in group 1 over a cleared depth buffer. + * * **Sway lags rotation.** The weapon trails the camera by a few frames. + * Without it the gun is welded to the view and the whole image feels rigid; + * with it, turning has weight. Deliberately small — this is a competitive + * shooter, and a viewmodel that swings across the screen hides targets. + * + * The muzzle transform (`SOCKET_MUZZLE`) is a *presentation* anchor only. The + * authoritative shot origin is derived from the server's player position, never + * from this node, so a tampered viewmodel cannot move where bullets come from. + */ + +/** Resting offset from the camera: right, down, forward, in metres. */ +const REST = new Vector3(0.21, -0.17, 0.34); + +/** + * Base yaw of the viewmodel. + * + * The carbine is modelled with its barrel along Blender -Y, which the glTF + * exporter maps to +Z. Babylon's glTF loader then wraps the import in a + * `__root__` node scaled (1, 1, -1) to convert right-handed glTF into its own + * left-handed space — so the barrel ends up pointing along -Z, straight back at + * the player. It rendered as a rifle held stock-first. + * + * Turning the whole thing round is the fix. Doing it here rather than in the + * generator keeps the exported asset in the orientation the rest of the glTF + * ecosystem expects. + */ +const BASE_YAW = Math.PI; + +/** + * Viewmodel scale. + * + * Real games render the weapon through a second, narrower camera so a + * true-scale rifle does not swallow the screen. This build has one camera at a + * 90° field of view, where a 0.74 m carbine held at arm's length covers most of + * the frame — which is exactly how it first looked. Scaling the mesh is the + * cheap equivalent and is indistinguishable in the result. + */ +const VIEW_SCALE = 0.62; + +/** How far the weapon may trail the view, in radians of camera rotation. */ +const SWAY_LIMIT = 0.045; +/** Fraction of the gap closed per 60 Hz frame. Lower is heavier. */ +const SWAY_RECOVERY = 0.14; + +const BOB_SPEED = 0.011; +const BOB_AMOUNT = 0.011; + +export class Viewmodel { + private readonly root: TransformNode; + private readonly muzzle: TransformNode | null; + private bobPhase = 0; + private swayYaw = 0; + private swayPitch = 0; + private lastYaw: number; + private lastPitch: number; + + constructor(scene: Scene, camera: Camera, assets: AssetSet) { + const container = assets.models.get("carbine"); + if (!container) throw new Error("carbine model not loaded"); + + const [root] = placeAll(container, "viewmodel", [ + { position: REST.clone(), scaling: new Vector3(VIEW_SCALE, VIEW_SCALE, VIEW_SCALE) }, + ]); + if (!root) throw new Error("carbine produced no root node"); + + this.root = root; + this.root.parent = camera; + this.root.rotation = new Vector3(0, BASE_YAW, 0); + + for (const mesh of this.root.getChildMeshes() as Mesh[]) { + // Drawn after the world, over a cleared depth buffer, so it can never + // intersect level geometry. + mesh.renderingGroupId = 1; + mesh.isPickable = false; + // A viewmodel casting shadows into the world would be visible as a + // floating rifle-shaped shadow with no owner. + mesh.receiveShadows = false; + mesh.alwaysSelectAsActiveMesh = true; + } + scene.setRenderingAutoClearDepthStencil(1, true, true, false); + + // A light that only ever touches the weapon. + // + // The yard is a night scene lit by distant sodium lamps, so a weapon held + // at the camera sits in shadow almost everywhere and renders as a black + // cut-out. Every first-person game solves this with a rig light; without + // it the gun is only visible when the player happens to stand under a lamp. + // `includedOnlyMeshes` keeps it strictly off the world, so it cannot + // brighten level geometry or give away a player's position. + const fill = new HemisphericLight("viewmodel-fill", new Vector3(-0.3, 1, -0.6), scene); + fill.intensity = 1.9; + fill.diffuse = new Color3(0.72, 0.76, 0.88); + fill.groundColor = new Color3(0.2, 0.17, 0.14); + fill.specular = new Color3(0.5, 0.52, 0.58); + fill.includedOnlyMeshes = this.root.getChildMeshes(); + fill.parent = camera; + + this.muzzle = + (this.root.getDescendants().find((node) => node.name.includes("SOCKET_MUZZLE")) as + TransformNode | undefined) ?? null; + + const rotation = (camera as unknown as { rotation?: Vector3 }).rotation; + this.lastYaw = rotation?.y ?? 0; + this.lastPitch = rotation?.x ?? 0; + } + + /** World-space muzzle position, for flash and tracer origins. */ + muzzlePosition(): Vector3 | null { + return this.muzzle ? this.muzzle.getAbsolutePosition() : null; + } + + /** + * @param deltaMs frame time + * @param speed horizontal speed in m/s, for the walk bob + * @param yaw current camera yaw, radians + * @param pitch current camera pitch, radians + */ + update(deltaMs: number, speed: number, yaw: number, pitch: number): void { + // Normalise the frame so behaviour does not change with frame rate. + const frames = Math.min(deltaMs / 16.667, 4); + + // Sway: accumulate the view delta, then bleed it off. + const dYaw = yaw - this.lastYaw; + const dPitch = pitch - this.lastPitch; + this.lastYaw = yaw; + this.lastPitch = pitch; + + this.swayYaw = clamp(this.swayYaw - dYaw, -SWAY_LIMIT, SWAY_LIMIT); + this.swayPitch = clamp(this.swayPitch - dPitch, -SWAY_LIMIT, SWAY_LIMIT); + const recovery = 1 - Math.pow(1 - SWAY_RECOVERY, frames); + this.swayYaw -= this.swayYaw * recovery; + this.swayPitch -= this.swayPitch * recovery; + + // Bob: advances with distance travelled, not with time, so standing still + // is still and sprinting bobs faster without a separate state machine. + this.bobPhase += speed * deltaMs * BOB_SPEED; + const bobX = Math.cos(this.bobPhase) * BOB_AMOUNT * Math.min(speed / 4, 1); + const bobY = Math.abs(Math.sin(this.bobPhase)) * BOB_AMOUNT * Math.min(speed / 4, 1); + + this.root.position.set(REST.x + bobX, REST.y - bobY, REST.z); + this.root.rotation.set(this.swayPitch, BASE_YAW + this.swayYaw, this.swayYaw * 0.4); + } + + dispose(): void { + this.root.dispose(); + } +} + +function clamp(value: number, lo: number, hi: number): number { + return value < lo ? lo : value > hi ? hi : value; +} diff --git a/apps/game/src/world.ts b/apps/game/src/world.ts index 1ab3a85..3b85160 100644 --- a/apps/game/src/world.ts +++ b/apps/game/src/world.ts @@ -9,18 +9,25 @@ import { ImageProcessingConfiguration, Mesh, MeshBuilder, - PBRMetallicRoughnessMaterial, PointLight, Scene, ShadowGenerator, StandardMaterial, - Texture, Vector3, type AbstractEngine, type Camera, } from "@babylonjs/core"; import { ARDAVAN_YARD, type CollisionMap } from "@nightcell7/multiplayer-sim"; import type { Aabb } from "@nightcell7/multiplayer-sim"; +import { + createTiledMaterial, + loadAssets, + meshesUnder, + placeAll, + type AssetSet, + type ModelName, + type Placement, +} from "./assets"; /** * Visual dressing for ARDAVAN YARD. @@ -37,9 +44,12 @@ import type { Aabb } from "@nightcell7/multiplayer-sim"; * The collision map is checksum-verified and will be edited; a positional * lookup would silently mis-skin the yard the first time a box moves. * - * All textures are generated procedurally at runtime. That is a deliberate - * choice, not a placeholder: CLAUDE.md forbids shipping any asset without - * provenance, and a canvas we draw ourselves has trivially clean provenance. + * The yard used to be skinned with one `MeshBuilder` box per collision volume + * and canvas-generated noise textures — a greybox. It is now built from the + * generated model set in `apps/game/public/assets` (see `tools/art`), placed to + * fill exactly the same volumes. Rule 1 is unchanged and is what keeps the art + * honest: a prop is tiled or scaled to its volume rather than the volume being + * adjusted to suit a prop. */ /** Palette, mirrored from the site's DIVIDED SIGNAL tokens. */ @@ -57,6 +67,7 @@ const PALETTE = { export interface WorldHandles { readonly shadows: ShadowGenerator; readonly pipeline: DefaultRenderingPipeline; + readonly assets: AssetSet; } interface Volume { @@ -78,7 +89,16 @@ function volumeOf(box: Aabb): Volume { * Semantic class of a collision volume, derived from its shape and position. * Keeps the skinning stable across map edits. */ -type VolumeKind = "ground" | "perimeter" | "deck" | "tank" | "container" | "block"; +type VolumeKind = + | "ground" + | "perimeter" + | "deck" + | "tank" + | "hardpoint" + | "pipe_rack" + | "stair" + | "container" + | "block"; function classify(v: Volume, map: CollisionMap): VolumeKind { const { size, centre } = v; @@ -101,69 +121,21 @@ function classify(v: Volume, map: CollisionMap): VolumeKind { // Storage tanks: tall footprint blocks on the east lane. if (size.y >= 6 && footprint >= 60) return "tank"; - // Shipping containers: the classic 6 x 3 x 6-ish yard blocks. - if (size.y >= 2 && size.y <= 3.2 && footprint >= 25) return "container"; + // Ramp steps: the only 1.5 m-tall volumes in the map. + if (size.y <= 1.6) return "stair"; - return "block"; -} + // The pipe rack is the one long, mid-height run. + if (size.y >= 3.5 && Math.max(size.x, size.z) >= 40) return "pipe_rack"; -// ---------------------------------------------------------------- textures + // The central objective is wide, low and much larger in plan than a + // container stack. Checked before `container` because it also falls inside + // that height band. + if (size.y <= 3.0 && footprint >= 80) return "hardpoint"; -/** - * Procedural noise texture used to break up flat PBR surfaces. - * `tint` biases the speckle so concrete, steel and rust each read differently. - */ -function noiseTexture( - scene: Scene, - name: string, - tint: Color3, - opts: { - size?: number; - density?: number; - streaks?: boolean; - uScale?: number; - vScale?: number; - } = {}, -): DynamicTexture { - const size = opts.size ?? 512; - const density = opts.density ?? 0.55; - const texture = new DynamicTexture(name, { width: size, height: size }, scene, false); - const ctx = texture.getContext() as unknown as CanvasRenderingContext2D; - - const base = tint.scale(255); - ctx.fillStyle = `rgb(${base.r | 0}, ${base.g | 0}, ${base.b | 0})`; - ctx.fillRect(0, 0, size, size); - - // Speckle: fine grit that survives mipmapping at yard scale. - const grains = Math.floor(size * size * density * 0.02); - for (let i = 0; i < grains; i += 1) { - const x = Math.random() * size; - const y = Math.random() * size; - const shade = (Math.random() - 0.5) * 60; - const r = Math.min(255, Math.max(0, base.r + shade)); - const g = Math.min(255, Math.max(0, base.g + shade)); - const b = Math.min(255, Math.max(0, base.b + shade)); - ctx.fillStyle = `rgba(${r | 0}, ${g | 0}, ${b | 0}, 0.55)`; - ctx.fillRect(x, y, 1 + Math.random() * 2, 1 + Math.random() * 2); - } - - // Vertical streaking reads as weathering / runoff on tanks and containers. - if (opts.streaks) { - for (let i = 0; i < size / 8; i += 1) { - const x = Math.random() * size; - const w = 1 + Math.random() * 3; - const h = size * (0.2 + Math.random() * 0.7); - ctx.fillStyle = `rgba(0, 0, 0, ${0.04 + Math.random() * 0.07})`; - ctx.fillRect(x, Math.random() * size * 0.3, w, h); - } - } + // Shipping containers: the classic yard blocks, and the cross-link stacks. + if (size.y >= 2 && size.y <= 3.2 && footprint >= 25) return "container"; - texture.update(false); - texture.wrapU = Texture.WRAP_ADDRESSMODE; - texture.wrapV = Texture.WRAP_ADDRESSMODE; - texture.uScale = opts.uScale ?? 1; - texture.vScale = opts.vScale ?? 1; - return texture; + return "block"; } /** @@ -258,12 +230,55 @@ function skyTexture(scene: Scene): DynamicTexture { // ------------------------------------------------------------------ build -export function buildWorld( +/** + * Tile a model along one horizontal axis to fill a volume exactly. + * + * The count is rounded to the nearest whole section and the remainder is taken + * up by scaling, so a 68 m catwalk built from 4 m decks has no gap at the end + * and no section hanging over the edge. The scale correction is always within a + * few percent, which is invisible, whereas a gap in a walkway is not. + */ +function tileAlong( + axis: "x" | "z", + v: Volume, + sectionLength: number, + baseY?: number, +): { placements: Placement[]; scale: number } { + const span = axis === "x" ? v.size.x : v.size.z; + const count = Math.max(1, Math.round(span / sectionLength)); + const scale = span / (count * sectionLength); + const y = baseY ?? v.box.min.y; + + const placements: Placement[] = []; + for (let i = 0; i < count; i += 1) { + const offset = -span / 2 + (i + 0.5) * (span / count); + placements.push({ + position: + axis === "x" + ? new Vector3(v.centre.x + offset, y, v.centre.z) + : new Vector3(v.centre.x, y, v.centre.z + offset), + rotationY: axis === "x" ? Math.PI / 2 : 0, + scaling: axis === "x" ? new Vector3(1, 1, scale) : new Vector3(1, 1, scale), + }); + } + return { placements, scale }; +} + +/** Native footprint of each tiled model, in metres. Must match `tools/art`. */ +const SECTION = { + wall: 6.0, // wall.glb spans 6 m along its length + deck: 4.0, + pipe_rack: 5.0, + containerWidth: 2.9, + containerLength: 6.0, +} as const; + +export async function buildWorld( scene: Scene, engine: AbstractEngine, camera: Camera, map: CollisionMap = ARDAVAN_YARD, -): WorldHandles { +): Promise { scene.clearColor = new Color4(PALETTE.ink.r, PALETTE.ink.g, PALETTE.ink.b, 1); scene.ambientColor = new Color3(0.08, 0.1, 0.14); @@ -275,6 +290,8 @@ export function buildWorld( // sodium light, not as a black void the far perimeter falls into. scene.fogColor = new Color3(0.085, 0.09, 0.115); + const assets = await loadAssets(scene); + // ------------------------------------------------------------------ sky const sky = MeshBuilder.CreateSphere( "sky", @@ -295,8 +312,12 @@ export function buildWorld( // Cool fill from the sky, warm bounce from sodium-lit ground. This carries // most of the shadow detail — with a near-black palette the fill is what // separates "moody" from "an unlit scene". + // Carries most of the image. The yard is played looking north into the + // dawn, which means the camera almost always sees the *shadowed* face of + // every container and wall; without a strong fill those faces are black + // silhouettes and the lanes stop reading as space you can move through. const ambient = new HemisphericLight("ambient", new Vector3(0.1, 1, 0.05), scene); - ambient.intensity = 1.15; + ambient.intensity = 2.15; ambient.diffuse = new Color3(0.34, 0.45, 0.66); ambient.groundColor = new Color3(0.22, 0.16, 0.12); ambient.specular = new Color3(0.16, 0.2, 0.26); @@ -305,7 +326,7 @@ export function buildWorld( // what produces the long shadows the yard reads by. const key = new DirectionalLight("false-dawn", new Vector3(0.12, -0.2, 1), scene); key.position = new Vector3(-10, 26, -95); - key.intensity = 3.4; + key.intensity = 4.2; key.diffuse = PALETTE.dustGold; key.specular = new Color3(0.9, 0.75, 0.5); @@ -313,7 +334,7 @@ export function buildWorld( // instead of dissolving into it. const rim = new DirectionalLight("rim", new Vector3(-0.25, -0.35, -1), scene); rim.position = new Vector3(20, 30, 90); - rim.intensity = 0.9; + rim.intensity = 1.35; rim.diffuse = new Color3(0.4, 0.58, 0.78); rim.specular = new Color3(0.5, 0.68, 0.85); @@ -329,135 +350,128 @@ export function buildWorld( const glow = new GlowLayer("glow", scene, { blurKernelSize: 48 }); glow.intensity = 0.85; - // ------------------------------------------------------------ materials - - const groundMat = new PBRMetallicRoughnessMaterial("mat-ground", scene); - groundMat.baseTexture = noiseTexture(scene, "tex-ground", PALETTE.concrete, { - density: 0.8, - uScale: 26, - vScale: 38, - }); - groundMat.metallic = 0.08; - groundMat.roughness = 0.86; - - const wallMat = new PBRMetallicRoughnessMaterial("mat-wall", scene); - // Kept dark: the perimeter is a boundary, not a feature. Lit any brighter it - // becomes a bright slab that pulls the eye away from the lanes. - wallMat.baseTexture = noiseTexture(scene, "tex-wall", PALETTE.concrete.scale(0.4), { - density: 0.6, - streaks: true, - uScale: 14, - vScale: 3, - }); - wallMat.metallic = 0.05; - wallMat.roughness = 0.92; - - const steelMat = new PBRMetallicRoughnessMaterial("mat-steel", scene); - steelMat.baseTexture = noiseTexture(scene, "tex-steel", PALETTE.steel, { density: 0.4 }); - steelMat.metallic = 0.78; - steelMat.roughness = 0.42; - - const tankMat = new PBRMetallicRoughnessMaterial("mat-tank", scene); - tankMat.baseTexture = noiseTexture(scene, "tex-tank", PALETTE.rust.scale(0.9), { - density: 0.5, - streaks: true, - uScale: 3, - vScale: 2, - }); - tankMat.metallic = 0.62; - tankMat.roughness = 0.62; - - // Containers carry the only saturated colour in the yard. Team sides read at - // a glance without a minimap: red toward Nightcell (south), cyan toward the - // Directorate (north). - const containerSouth = new PBRMetallicRoughnessMaterial("mat-container-s", scene); - containerSouth.baseTexture = noiseTexture(scene, "tex-cs", PALETTE.signalRed.scale(0.85), { - density: 0.45, - streaks: true, - }); - containerSouth.metallic = 0.25; - containerSouth.roughness = 0.68; - - const containerNorth = new PBRMetallicRoughnessMaterial("mat-container-n", scene); - containerNorth.baseTexture = noiseTexture(scene, "tex-cn", PALETTE.signalCyan.scale(0.7), { - density: 0.45, - streaks: true, - }); - containerNorth.metallic = 0.25; - containerNorth.roughness = 0.68; + // ------------------------------------------------------------- geometry - const blockMat = new PBRMetallicRoughnessMaterial("mat-block", scene); - blockMat.baseTexture = noiseTexture(scene, "tex-block", PALETTE.steel.scale(0.8), { - density: 0.5, - }); - blockMat.metallic = 0.5; - blockMat.roughness = 0.55; + const casters: Mesh[] = []; + const model = (name: ModelName) => { + const container = assets.models.get(name); + if (!container) throw new Error(`model not loaded: ${name}`); + return container; + }; - // ------------------------------------------------------------- geometry + /** Place a model and register everything it produced as a shadow caster. */ + const put = (name: ModelName, label: string, placements: readonly Placement[]) => { + if (placements.length === 0) return; + casters.push(...meshesUnder(placeAll(model(name), label, placements))); + }; map.boxes.forEach((box, index) => { const v = volumeOf(box); const kind = classify(v, map); - const mesh = MeshBuilder.CreateBox( - `${kind}_${index}`, - { width: v.size.x, height: v.size.y, depth: v.size.z }, - scene, - ); - mesh.position = v.centre; - mesh.checkCollisions = false; - mesh.isPickable = false; - switch (kind) { - case "ground": - mesh.material = groundMat; - mesh.receiveShadows = true; + case "ground": { + // The one surface still built as a primitive: it is a flat slab, and a + // tiled model would only add draw calls and seams. + const ground = MeshBuilder.CreateBox( + "ground", + { width: v.size.x, height: v.size.y, depth: v.size.z }, + scene, + ); + ground.position = v.centre; + ground.isPickable = false; + ground.receiveShadows = true; + ground.material = createTiledMaterial(scene, "concrete", v.size.x / 4, v.size.z / 4); + ground.freezeWorldMatrix(); break; - case "perimeter": - mesh.material = wallMat; - mesh.receiveShadows = true; + } + + case "perimeter": { + // Walls run along whichever horizontal axis is longer. + const axis = v.size.x >= v.size.z ? "x" : "z"; + put("wall", `wall_${index}`, tileAlong(axis, v, SECTION.wall).placements); break; - case "deck": - mesh.material = steelMat; - mesh.receiveShadows = true; - shadows.addShadowCaster(mesh); + } + + case "deck": { + const axis = v.size.x >= v.size.z ? "x" : "z"; + put("deck", `deck_${index}`, tileAlong(axis, v, SECTION.deck).placements); break; - case "tank": - mesh.material = tankMat; - mesh.receiveShadows = true; - shadows.addShadowCaster(mesh); + } + + case "pipe_rack": { + const axis = v.size.x >= v.size.z ? "x" : "z"; + put("pipe_rack", `pipes_${index}`, tileAlong(axis, v, SECTION.pipe_rack).placements); break; - case "container": - mesh.material = v.centre.z > 0 ? containerSouth : containerNorth; - mesh.receiveShadows = true; - shadows.addShadowCaster(mesh); + } + + case "tank": { + put("tank", `tank_${index}`, [ + { position: new Vector3(v.centre.x, v.box.min.y, v.centre.z) }, + ]); + break; + } + + case "hardpoint": { + put("hardpoint", `hardpoint_${index}`, [ + { position: new Vector3(v.centre.x, v.box.min.y, v.centre.z) }, + ]); break; - default: - mesh.material = blockMat; - mesh.receiveShadows = true; - shadows.addShadowCaster(mesh); + } + + case "stair": { + // The stair model climbs toward -z. The west ramp (z > 0) climbs that + // way already; the east ramp climbs the other way and is turned round. + // This is map-specific on purpose — deriving the direction would need + // the neighbouring steps, and the collision map is the thing that + // defines "up" here. + put("stair", `stair_${index}`, [ + { + position: new Vector3(v.centre.x, v.box.min.y, v.centre.z), + rotationY: v.centre.z > 0 ? 0 : Math.PI, + }, + ]); break; - } + } - // Static geometry: freezing the world matrix and the material removes the - // per-frame transform and shader rebind cost for ~30 meshes. - mesh.freezeWorldMatrix(); + case "container": + case "block": { + // Fill the volume with a grid of container-sized units, so a 6 x 6 + // volume gets two side by side and a 4 x 12 cross-link gets two + // end to end. + const nx = Math.max(1, Math.round(v.size.x / SECTION.containerWidth)); + const nz = Math.max(1, Math.round(v.size.z / SECTION.containerLength)); + const placements: Placement[] = []; + for (let ix = 0; ix < nx; ix += 1) { + for (let iz = 0; iz < nz; iz += 1) { + placements.push({ + position: new Vector3( + v.centre.x - v.size.x / 2 + (ix + 0.5) * (v.size.x / nx), + v.box.min.y, + v.centre.z - v.size.z / 2 + (iz + 0.5) * (v.size.z / nz), + ), + // Alternate the door end so a row of containers is not a + // repeating stamp. + rotationY: (ix + iz) % 2 === 0 ? 0 : Math.PI, + scaling: new Vector3( + v.size.x / nx / SECTION.containerWidth, + v.size.y / 3.0, + v.size.z / nz / SECTION.containerLength, + ), + }); + } + } + put("container", `container_${index}`, placements); + break; + } + } }); // --------------------------------------------------------- sodium lamps - // Lamp masts along the three lanes. These are the yard's practical lights: - // each one is an emissive head (picked up by the glow layer) plus a real - // point light with a tight radius so the pools of light stay readable. - const lampMat = new StandardMaterial("mat-lamp", scene); - lampMat.disableLighting = true; - lampMat.emissiveColor = PALETTE.sodium; - - const mastMat = new PBRMetallicRoughnessMaterial("mat-mast", scene); - mastMat.baseColor = PALETTE.steel.scale(0.5); - mastMat.metallic = 0.8; - mastMat.roughness = 0.5; - + // Lamp masts along the three lanes. Each is the generated mast model plus a + // real point light at the head, so the fitting and the pool of light it + // casts cannot drift apart. const lampSpots: Array<[number, number]> = [ [-28, -24], [-28, 8], @@ -472,39 +486,24 @@ export function buildWorld( [14, 44], ]; + put( + "lamp_mast", + "lamp", + lampSpots.map(([x, z]) => ({ position: new Vector3(x, 0, z) })), + ); + lampSpots.forEach(([x, z], i) => { - const mast = MeshBuilder.CreateCylinder( - `mast_${i}`, - { height: 9, diameter: 0.28, tessellation: 8 }, - scene, - ); - mast.position = new Vector3(x, 4.5, z); - mast.material = mastMat; - mast.isPickable = false; - mast.freezeWorldMatrix(); - shadows.addShadowCaster(mast); - - const head = MeshBuilder.CreateBox( - `lamp_${i}`, - { width: 0.9, height: 0.22, depth: 0.5 }, - scene, - ); - head.position = new Vector3(x, 9.05, z); - head.material = lampMat; - head.isPickable = false; - head.freezeWorldMatrix(); - - // Only a subset get real point lights — eleven dynamic lights would cost - // more than they add, and Babylon's per-mesh light cap would start - // dropping them unpredictably. - if (i % 2 === 0) { - const lamp = new PointLight(`lamp-light_${i}`, new Vector3(x, 8.6, z), scene); - lamp.diffuse = PALETTE.sodium; - lamp.specular = PALETTE.sodium; - lamp.intensity = 260; - lamp.range = 34; - lamp.falloffType = PointLight.FALLOFF_PHYSICAL; - } + // Every mast is lit. Lighting only half of them left long unlit stretches + // between pools, and the masts without a light read as broken fittings. + // Babylon's per-mesh light cap is raised below to keep them all active. + // Matches SOCKET_LAMP on lamp_mast.glb: 0.86 m out on the bracket arm, + // 8.5 m up. + const lamp = new PointLight(`lamp-light_${i}`, new Vector3(x + 0.86, 8.5, z), scene); + lamp.diffuse = PALETTE.sodium; + lamp.specular = PALETTE.sodium; + lamp.intensity = 210; + lamp.range = 40; + lamp.falloffType = PointLight.FALLOFF_PHYSICAL; }); // Two cold accent lights mark the opposing spawn ends, echoing the split @@ -521,6 +520,13 @@ export function buildWorld( northMark.intensity = 420; northMark.range = 40; + // Registering the source meshes is enough: Babylon renders their instances + // into the shadow map with them. + for (const mesh of casters) { + shadows.addShadowCaster(mesh); + mesh.receiveShadows = true; + } + // ------------------------------------------------------- post-processing const pipeline = new DefaultRenderingPipeline("post", true, scene, [camera]); @@ -540,7 +546,7 @@ export function buildWorld( pipeline.imageProcessingEnabled = true; pipeline.imageProcessing.toneMappingEnabled = true; pipeline.imageProcessing.toneMappingType = ImageProcessingConfiguration.TONEMAPPING_ACES; - pipeline.imageProcessing.exposure = 1.45; + pipeline.imageProcessing.exposure = 1.62; pipeline.imageProcessing.contrast = 1.25; pipeline.imageProcessing.vignetteEnabled = true; pipeline.imageProcessing.vignetteWeight = 2.4; @@ -565,5 +571,5 @@ export function buildWorld( pipeline.depthOfFieldEnabled = false; void engine; - return { shadows, pipeline }; + return { shadows, pipeline, assets }; } diff --git a/apps/game/vite.config.ts b/apps/game/vite.config.ts index aa5a496..80bdfcd 100644 --- a/apps/game/vite.config.ts +++ b/apps/game/vite.config.ts @@ -43,7 +43,7 @@ export default defineConfig({ handler: "NetworkOnly", }, { - urlPattern: /\.(?:ktx2|glb|webm|mp3|bin)$/, + urlPattern: /\.(?:ktx2|glb|webp|webm|mp3|bin)$/, handler: "CacheFirst", options: { cacheName: "nc7-content", diff --git a/apps/site/public/media/yard/central-hardpoint.webp b/apps/site/public/media/yard/central-hardpoint.webp index 6500b13..5a5b313 100644 Binary files a/apps/site/public/media/yard/central-hardpoint.webp and b/apps/site/public/media/yard/central-hardpoint.webp differ diff --git a/apps/site/public/media/yard/gantry-overlook.webp b/apps/site/public/media/yard/gantry-overlook.webp index 4189752..9abf819 100644 Binary files a/apps/site/public/media/yard/gantry-overlook.webp and b/apps/site/public/media/yard/gantry-overlook.webp differ diff --git a/apps/site/public/media/yard/manifest.json b/apps/site/public/media/yard/manifest.json index 4456075..3c62520 100644 --- a/apps/site/public/media/yard/manifest.json +++ b/apps/site/public/media/yard/manifest.json @@ -2,8 +2,8 @@ "generator": "tools/art/capture.mjs", "source": "In-engine capture of ARDAVAN_YARD from apps/game (Babylon.js).", "license": "Original work, © NIGHTCELL 7. No third-party assets.", - "commit": "9e0d892d8cf55eb61344c2335b65fef73261c127", - "capturedAt": "2026-07-26T00:04:21.086Z", + "commit": "bf4d13f789386baa436f5e17e69be82763b3e142", + "capturedAt": "2026-07-26T09:04:30.282Z", "viewport": { "width": 1920, "height": 1080 diff --git a/apps/site/public/media/yard/north-gate.webp b/apps/site/public/media/yard/north-gate.webp index 5850330..1f401a2 100644 Binary files a/apps/site/public/media/yard/north-gate.webp and b/apps/site/public/media/yard/north-gate.webp differ diff --git a/apps/site/public/media/yard/tank-row.webp b/apps/site/public/media/yard/tank-row.webp index b06a147..a4e22b3 100644 Binary files a/apps/site/public/media/yard/tank-row.webp and b/apps/site/public/media/yard/tank-row.webp differ diff --git a/apps/site/public/media/yard/west-catwalk.webp b/apps/site/public/media/yard/west-catwalk.webp index 6a3f28a..3344517 100644 Binary files a/apps/site/public/media/yard/west-catwalk.webp and b/apps/site/public/media/yard/west-catwalk.webp differ diff --git a/apps/site/public/media/yard/yard-approach.webp b/apps/site/public/media/yard/yard-approach.webp index ddcaa3c..b5d1116 100644 Binary files a/apps/site/public/media/yard/yard-approach.webp and b/apps/site/public/media/yard/yard-approach.webp differ diff --git a/package.json b/package.json index 4159934..c1382e5 100644 --- a/package.json +++ b/package.json @@ -22,21 +22,24 @@ "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test", "db:generate": "pnpm --filter @nightcell7/database generate", "db:migrate": "pnpm --filter @nightcell7/database migrate", - "loadtest": "pnpm --filter @nightcell7/tools-multiplayer-loadtest start" + "loadtest": "pnpm --filter @nightcell7/tools-multiplayer-loadtest start", + "assets:build": "node tools/art/build-assets.mjs", + "assets:capture": "pnpm --filter @nightcell7/game build && node tools/art/capture.mjs" }, "devDependencies": { + "@eslint/js": "^9.17.0", "@types/node": "^22.10.2", "@typescript-eslint/eslint-plugin": "^8.18.1", "@typescript-eslint/parser": "^8.18.1", "eslint": "^9.17.0", + "globals": "^15.14.0", + "playwright": "^1.62.0", "prettier": "^3.4.2", "tsup": "^8.3.5", "tsx": "^4.19.2", "typescript": "^5.7.2", - "vitest": "^2.1.8", - "@eslint/js": "^9.17.0", "typescript-eslint": "^8.18.1", - "globals": "^15.14.0" + "vitest": "^2.1.8" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 037e19c..c330ce3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: globals: specifier: ^15.14.0 version: 15.15.0 + playwright: + specifier: ^1.62.0 + version: 1.62.0 prettier: specifier: ^3.4.2 version: 3.9.6 @@ -4112,6 +4115,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5120,6 +5128,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} + hasBin: true + plist@3.1.1: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} @@ -9966,6 +9984,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -11024,6 +11045,14 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + playwright-core@1.62.0: {} + + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.1: dependencies: '@xmldom/xmldom': 0.9.10 diff --git a/tools/art/blender/__pycache__/_lib.cpython-311.pyc b/tools/art/blender/__pycache__/_lib.cpython-311.pyc new file mode 100644 index 0000000..611e75f Binary files /dev/null and b/tools/art/blender/__pycache__/_lib.cpython-311.pyc differ diff --git a/tools/art/blender/_lib.py b/tools/art/blender/_lib.py new file mode 100644 index 0000000..343c308 --- /dev/null +++ b/tools/art/blender/_lib.py @@ -0,0 +1,403 @@ +""" +Shared helpers for the NIGHTCELL 7 Blender asset generators. + +Every model in this game is generated by a script, never modelled by hand. +That is a hard requirement, not a stylistic one: CLAUDE.md forbids shipping any +asset without provenance, and a committed generator script is the strongest +provenance there is — the asset can be rebuilt from a commit, and a reviewer +can read exactly how it was made. + +Three rules hold everywhere in this package: + + 1. **One Blender metre is one game metre.** No import scaling, no "fix it in + the engine". The collision volumes in `@nightcell7/multiplayer-sim` are in + metres and the art has to agree with them. + 2. **Deterministic.** All randomness comes from a seeded generator, so + `git checkout && pnpm assets:build` reproduces the same mesh. An asset that + changes every build cannot be reviewed. + 3. **No embedded textures.** Meshes carry UVs and a *named* material slot. + Babylon binds the real PBR maps by that name at load time. Embedding them + per-GLB would ship the same steel texture a dozen times and blow the 15 MB + shell budget (PRD §30). +""" + +from __future__ import annotations + +import math +import os +import random +import sys +from dataclasses import dataclass + +import bmesh +import bpy +from mathutils import Vector + +# Material slot names. These are the contract with the engine: `world.ts` looks +# materials up by exactly these strings, so renaming one here silently +# un-skins every mesh that used it. +MAT_CONCRETE = "concrete" +MAT_STEEL = "steel" +MAT_RUST = "rust" +MAT_PAINT_RED = "paint_red" +MAT_PAINT_CYAN = "paint_cyan" +MAT_GRATING = "grating" +MAT_GLASS_EMISSIVE = "lamp_glass" +MAT_RUBBER = "rubber" + +ALL_MATERIALS = ( + MAT_CONCRETE, + MAT_STEEL, + MAT_RUST, + MAT_PAINT_RED, + MAT_PAINT_CYAN, + MAT_GRATING, + MAT_GLASS_EMISSIVE, + MAT_RUBBER, +) + +# Approximate viewer-facing colours, used only as the glTF baseColorFactor so a +# model is not black in a generic glTF viewer. The engine overrides these. +_MATERIAL_TINT = { + MAT_CONCRETE: (0.19, 0.20, 0.21, 1.0), + MAT_STEEL: (0.26, 0.28, 0.31, 1.0), + MAT_RUST: (0.35, 0.21, 0.14, 1.0), + MAT_PAINT_RED: (0.70, 0.19, 0.21, 1.0), + MAT_PAINT_CYAN: (0.23, 0.52, 0.55, 1.0), + MAT_GRATING: (0.22, 0.24, 0.26, 1.0), + MAT_GLASS_EMISSIVE: (1.0, 0.71, 0.36, 1.0), + MAT_RUBBER: (0.06, 0.06, 0.07, 1.0), +} + +_MATERIAL_SURFACE = { + # (metallic, roughness) + MAT_CONCRETE: (0.0, 0.88), + MAT_STEEL: (0.85, 0.42), + MAT_RUST: (0.35, 0.78), + MAT_PAINT_RED: (0.15, 0.62), + MAT_PAINT_CYAN: (0.15, 0.62), + MAT_GRATING: (0.80, 0.50), + MAT_GLASS_EMISSIVE: (0.0, 0.30), + MAT_RUBBER: (0.0, 0.94), +} + +# Texel density, in texels per metre, used when UV-unwrapping by box +# projection. One number everywhere is what makes surfaces look like they +# belong in the same world — mismatched density is the single most obvious +# "amateur" tell in a 3D scene. +TEXELS_PER_METRE = 128.0 +_UV_SCALE = 1.0 / 4.0 # one texture tile spans 4 m + + +# ---------------------------------------------------------------- scene setup + + +def reset_scene() -> None: + """Start from a genuinely empty file so generators cannot leak into each other.""" + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.unit_settings.system = "METRIC" + scene.unit_settings.scale_length = 1.0 + + +def rng(seed: int) -> random.Random: + """Seeded RNG. Never use the module-level `random` in a generator.""" + return random.Random(seed) + + +# ---------------------------------------------------------------- materials + + +def get_material(name: str) -> bpy.types.Material: + """Fetch or create the shared material named `name`.""" + if name in bpy.data.materials: + return bpy.data.materials[name] + + material = bpy.data.materials.new(name) + material.use_nodes = True + bsdf = material.node_tree.nodes.get("Principled BSDF") + if bsdf: + metallic, roughness = _MATERIAL_SURFACE.get(name, (0.0, 0.8)) + bsdf.inputs["Base Color"].default_value = _MATERIAL_TINT.get(name, (0.5, 0.5, 0.5, 1.0)) + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + if name == MAT_GLASS_EMISSIVE: + bsdf.inputs["Emission Color"].default_value = _MATERIAL_TINT[name] + bsdf.inputs["Emission Strength"].default_value = 4.0 + return material + + +# ------------------------------------------------------------------- meshes + + +@dataclass +class Part: + """A finished bmesh ready to be merged into an object under one material.""" + + bm: bmesh.types.BMesh + material: str + + +def new_bmesh() -> bmesh.types.BMesh: + return bmesh.new() + + +def add_box( + bm: bmesh.types.BMesh, + centre: tuple[float, float, float], + size: tuple[float, float, float], + rotation_z: float = 0.0, +) -> list[bmesh.types.BMFace]: + """Axis-aligned (optionally Z-rotated) box, sized in metres.""" + from mathutils import Matrix + + result = bmesh.ops.create_cube(bm, size=1.0) + verts = result["verts"] + + matrix = ( + Matrix.Translation(Vector(centre)) + @ Matrix.Rotation(rotation_z, 4, "Z") + @ Matrix.Diagonal(Vector(size)).to_4x4() + ) + bmesh.ops.transform(bm, matrix=matrix, verts=verts) + + faces: list[bmesh.types.BMFace] = [] + for vert in verts: + faces.extend(vert.link_faces) + return list(dict.fromkeys(faces)) + + +def add_cylinder( + bm: bmesh.types.BMesh, + centre: tuple[float, float, float], + radius: float, + height: float, + segments: int = 16, + axis: str = "Z", + cap: bool = True, +) -> None: + from mathutils import Matrix + + result = bmesh.ops.create_cone( + bm, + cap_ends=cap, + cap_tris=False, + segments=segments, + radius1=radius, + radius2=radius, + depth=height, + ) + verts = result["verts"] + + rotation = Matrix.Identity(4) + if axis == "X": + rotation = Matrix.Rotation(math.radians(90), 4, "Y") + elif axis == "Y": + rotation = Matrix.Rotation(math.radians(90), 4, "X") + + bmesh.ops.transform( + bm, matrix=Matrix.Translation(Vector(centre)) @ rotation, verts=verts + ) + + +def add_tube( + bm: bmesh.types.BMesh, + centre: tuple[float, float, float], + radius: float, + height: float, + segments: int = 16, + axis: str = "Z", +) -> None: + """Open-ended cylinder — cheaper than a capped one where the ends never show.""" + add_cylinder(bm, centre, radius, height, segments=segments, axis=axis, cap=False) + + +def bevel(bm: bmesh.types.BMesh, width: float = 0.01, segments: int = 1) -> None: + """ + Bevel every edge. + + Small bevels are the difference between "3D model" and "programmer box": + they catch a specular highlight along each edge, which is what tells the eye + an object is solid. 1 cm is enough at yard scale and costs few triangles. + """ + bmesh.ops.bevel( + bm, + geom=list(bm.verts) + list(bm.edges) + list(bm.faces), + offset=width, + segments=segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + + +# ----------------------------------------------------------------------- UVs + + +def box_unwrap(bm: bmesh.types.BMesh, scale: float = _UV_SCALE) -> None: + """ + Planar-project each face along its dominant axis. + + Cheap, seam-free enough for tiling industrial materials, and — unlike + Blender's Smart UV Project — completely deterministic, which the + reproducibility rule requires. + """ + uv_layer = bm.loops.layers.uv.verify() + + for face in bm.faces: + normal = face.normal + ax, ay, az = abs(normal.x), abs(normal.y), abs(normal.z) + + for loop in face.loops: + position = loop.vert.co + if az >= ax and az >= ay: + u, v = position.x, position.y + elif ax >= ay: + u, v = position.y, position.z + else: + u, v = position.x, position.z + loop[uv_layer].uv = (u * scale, v * scale) + + +# -------------------------------------------------------------------- object + + +def finish_object(name: str, parts: list[Part], shade_smooth_angle: float | None = None): + """ + Merge `parts` into one mesh object, one material slot per distinct material. + + Merging matters for draw calls: the yard places dozens of these, and a prop + split across six objects is six draw calls every frame instead of one. + """ + mesh = bpy.data.meshes.new(name) + merged = bmesh.new() + + materials: list[str] = [] + for part in parts: + if part.material not in materials: + materials.append(part.material) + + for part in parts: + index = materials.index(part.material) + box_unwrap(part.bm) + + # Copy the part into the merged mesh, tagging faces with their slot. + temp = bpy.data.meshes.new(f"{name}_tmp") + part.bm.to_mesh(temp) + part.bm.free() + + scratch = bmesh.new() + scratch.from_mesh(temp) + for face in scratch.faces: + face.material_index = index + scratch.to_mesh(temp) + scratch.free() + + merged.from_mesh(temp) + bpy.data.meshes.remove(temp) + + bmesh.ops.remove_doubles(merged, verts=list(merged.verts), dist=0.0001) + + if shade_smooth_angle is not None: + # Angle-based smoothing done in bmesh, not with an operator. + # + # `bpy.ops.object.shade_auto_smooth` needs a UI context and silently + # fails in --background. The previous version set every polygon smooth + # and relied on that operator to re-sharpen the hard edges; when it + # failed, cylinders ended up fully smooth and their end caps blended + # into the sides, so every pipe and vessel rendered with rounded, + # ball-like ends. + # + # Since Blender 4.1, marking an edge sharp is exactly how auto-smooth + # is expressed, so doing it directly is both correct and deterministic. + for face in merged.faces: + face.smooth = True + for edge in merged.edges: + if len(edge.link_faces) == 2 and edge.calc_face_angle(0.0) > shade_smooth_angle: + edge.smooth = False + + merged.to_mesh(mesh) + merged.free() + + for material_name in materials: + mesh.materials.append(get_material(material_name)) + + obj = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(obj) + return obj + + +def add_collider(name: str, centre: tuple[float, float, float], size: tuple[float, float, float]): + """ + Add a `COL_`-prefixed collision proxy (CLAUDE.md). + + Exported alongside the visual mesh so the engine — or a future physics + pass — can use a cheap box hull instead of the render geometry. + """ + bm = bmesh.new() + add_box(bm, centre, size) + mesh = bpy.data.meshes.new(f"COL_{name}") + bm.to_mesh(mesh) + bm.free() + obj = bpy.data.objects.new(f"COL_{name}", mesh) + bpy.context.collection.objects.link(obj) + return obj + + +def add_socket(name: str, location: tuple[float, float, float], rotation=(0.0, 0.0, 0.0)): + """Add a `SOCKET_`-prefixed empty — attachment points such as SOCKET_MUZZLE.""" + empty = bpy.data.objects.new(f"SOCKET_{name}", None) + empty.empty_display_type = "ARROWS" + empty.empty_display_size = 0.1 + empty.location = location + empty.rotation_euler = rotation + bpy.context.collection.objects.link(empty) + return empty + + +# ------------------------------------------------------------------- export + + +def export_glb(path: str) -> None: + """Export the whole scene to GLB, without textures or lights.""" + os.makedirs(os.path.dirname(path), exist_ok=True) + + bpy.ops.export_scene.gltf( + filepath=path, + export_format="GLB", + export_apply=True, + export_yup=True, + # No images: materials are bound by name in the engine. + export_materials="EXPORT", + export_image_format="NONE", + export_cameras=False, + export_lights=False, + export_extras=False, + export_animations=False, + export_skins=False, + export_morph=False, + # Empties carry the SOCKET_ markers, so they must survive the export. + use_visible=False, + use_renderable=False, + export_texcoords=True, + export_normals=True, + export_tangents=False, + ) + + size = os.path.getsize(path) + tris = sum( + len(o.data.loop_triangles) + for o in bpy.data.objects + if o.type == "MESH" and (o.data.calc_loop_triangles() or True) + ) + print(f"EXPORTED {os.path.basename(path)} bytes={size} tris={tris}") + + +def output_path(filename: str) -> str: + """Resolve an output path from `--output ` on the Blender command line.""" + argv = sys.argv + if "--output" in argv: + base = argv[argv.index("--output") + 1] + else: + base = os.path.join(os.path.dirname(__file__), "..", "..", "..", "build", "models") + return os.path.abspath(os.path.join(base, filename)) diff --git a/tools/art/blender/character.py b/tools/art/blender/character.py new file mode 100644 index 0000000..a9c155a --- /dev/null +++ b/tools/art/blender/character.py @@ -0,0 +1,122 @@ +""" +Player character. + +This asset exists because remote players are currently *invisible*: the client +interpolates their positions (`RemotePlayerInterpolator`) and +`NetClient.remotePosition()` returns them, but nothing in the game ever calls +it or creates a mesh. A six-a-side match therefore renders an empty yard. + +Design constraints, in priority order: + + 1. **Readable silhouette.** A competitive shooter lives or dies on whether you + can identify a player shape in a fraction of a second against cluttered + industrial geometry. Chunky pauldrons, a helmet with a distinct brow, and a + backpack give a recognisable outline from any angle. + 2. **Team legibility without pay-to-win.** Team identity is carried by + material (`paint_red` / `paint_cyan`), not by geometry, so the two sides + are exactly the same size and present exactly the same hitbox. PRD § + "Multiplayer Alpha is free and cannot be pay-to-win" — a smaller-silhouette + skin would be precisely that. + 3. **Matches the capsule the server simulates.** 1.8 m standing, roughly + 0.6 m across the shoulders, centred on the origin in plan so the mesh sits + on the simulated position without an offset fudge. + +Unrigged and unanimated on purpose: V1 has no animation system, and a static +mesh that reads correctly beats a rigged one that cannot be driven. It is built +in a neutral standing pose so a skeleton can be added later without redoing the +silhouette. +""" + +from __future__ import annotations + +import math +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import _lib as L # noqa: E402 + +HEIGHT = 1.8 + + +def build(seed: int = 31) -> None: + L.reset_scene() + + armour = L.new_bmesh() # team-coloured plate + gear = L.new_bmesh() # webbing, boots, weapon — always dark + + # ---- legs ------------------------------------------------------------ + for sx in (-1, 1): + x = sx * 0.115 + # Thigh and shin, slightly tapered by stacking two boxes. + L.add_box(gear, centre=(x, 0.0, 0.24), size=(0.17, 0.20, 0.48)) + L.add_box(gear, centre=(x, 0.005, 0.66), size=(0.19, 0.23, 0.40)) + # Boot: wider than the shin, which anchors the figure to the ground. + L.add_box(gear, centre=(x, -0.03, 0.05), size=(0.20, 0.30, 0.10)) + # Knee pad, in team colour so the legs are not a dark mass. + L.add_box(armour, centre=(x, -0.10, 0.47), size=(0.16, 0.06, 0.14)) + + # ---- torso ----------------------------------------------------------- + # Built as a stack rather than one box so the chest tapers into the + # shoulders. Heights are chosen to leave NO vertical gap anywhere up the + # figure: the first version ended the torso at 1.32 and started the neck at + # 1.41, which rendered as a head floating above the body. + L.add_box(gear, centre=(0.0, 0.0, 0.90), size=(0.33, 0.22, 0.22)) # waist / belt + L.add_box(armour, centre=(0.0, 0.0, 1.10), size=(0.40, 0.25, 0.24)) # lower chest + L.add_box(armour, centre=(0.0, 0.0, 1.29), size=(0.44, 0.27, 0.20)) # upper chest, widest + L.add_box(armour, centre=(0.0, -0.15, 1.16), size=(0.34, 0.06, 0.34)) # plate carrier front + # Collar bridging chest to neck, so the head sits ON the body. + L.add_box(gear, centre=(0.0, 0.0, 1.41), size=(0.26, 0.20, 0.08)) + + # Magazine pouches across the front — small shapes that catch the light. + for i in range(3): + L.add_box(gear, centre=(-0.11 + i * 0.11, -0.19, 1.02), size=(0.09, 0.06, 0.14)) + + # Backpack: pushes the silhouette asymmetric front-to-back, which is what + # makes facing direction readable at distance. + L.add_box(gear, centre=(0.0, 0.185, 1.18), size=(0.30, 0.15, 0.38)) + L.add_cylinder(gear, centre=(0.0, 0.26, 1.33), radius=0.05, height=0.28, segments=8, axis="X") + + # ---- shoulders and arms --------------------------------------------- + for sx in (-1, 1): + # Pauldron — the strongest silhouette element on the upper body. Sits + # just outboard of the chest and overlaps it, so there is no seam. + L.add_box(armour, centre=(sx * 0.245, 0.0, 1.31), size=(0.15, 0.26, 0.19)) + # Upper arm hangs from under the pauldron. + L.add_box(gear, centre=(sx * 0.255, -0.005, 1.13), size=(0.125, 0.16, 0.24)) + # Elbow, then a forearm angled in and forward as if holding a weapon. + L.add_box(gear, centre=(sx * 0.245, -0.03, 1.00), size=(0.115, 0.14, 0.10)) + L.add_box(gear, centre=(sx * 0.205, -0.16, 0.97), size=(0.105, 0.26, 0.105)) + # Glove. + L.add_box(gear, centre=(sx * 0.185, -0.29, 0.96), size=(0.095, 0.10, 0.095)) + + # ---- head ------------------------------------------------------------ + L.add_box(gear, centre=(0.0, 0.0, 1.485), size=(0.125, 0.13, 0.09)) # neck + L.add_box(armour, centre=(0.0, 0.0, 1.60), size=(0.215, 0.245, 0.185)) # helmet shell + L.add_box(armour, centre=(0.0, 0.0, 1.695), size=(0.185, 0.215, 0.045)) # crown + L.add_box(armour, centre=(0.0, -0.135, 1.585), size=(0.195, 0.05, 0.085)) # brow / visor lip + L.add_box(gear, centre=(0.0, -0.115, 1.535), size=(0.165, 0.05, 0.07)) # face shadow + # Comms boom, a small asymmetric tell for facing. + L.add_cylinder(gear, centre=(0.07, -0.115, 1.515), radius=0.012, height=0.12, segments=5, axis="Y") + + L.bevel(armour, width=0.008) + L.bevel(gear, width=0.006) + + L.finish_object( + "character", + [L.Part(armour, L.MAT_PAINT_RED), L.Part(gear, L.MAT_RUBBER)], + ) + + # Collision proxy matching the simulated capsule. + L.add_collider("character", (0.0, 0.0, HEIGHT / 2), (0.6, 0.4, HEIGHT)) + # Where a weapon world-model attaches, and where tracers should originate + # for other players' shots. + L.add_socket("WEAPON", (0.22, -0.30, 0.98), rotation=(0.0, 0.0, 0.0)) + L.add_socket("HEAD", (0.0, 0.0, 1.62)) + + L.export_glb(L.output_path("character.glb")) + _ = seed, math + + +build() diff --git a/tools/art/blender/container.py b/tools/art/blender/container.py new file mode 100644 index 0000000..a85ad42 --- /dev/null +++ b/tools/art/blender/container.py @@ -0,0 +1,165 @@ +""" +Shipping container. + +The centre lane of Ardavan Yard is a container yard, so this is the most-seen +prop in the game and the one worth spending triangles on. Three details do +almost all the work of making it read as a real container rather than a +coloured box: + + 1. **Corrugation.** The trapezoidal rib profile down the long walls. It is + the single most recognisable thing about a container and it gives the + sodium lamps something to rake across. + 2. **Corner castings.** The cast steel blocks at all eight corners. Real + containers are lifted by them, and their absence is what makes a box look + like a box. + 3. **A door end.** Two doors, four locking rods, hinges and handles. It gives + the prop a front, which means placements can be varied meaningfully. + +Dimensions are driven by the collision volume, not by the ISO standard: the +yard's container volumes are 6 x 3 x 6 m and the engine places two of these +side by side to fill one. `world.ts` and this file must agree, because the +project rule is that what you see is what you collide with. +""" + +from __future__ import annotations + +import os +import sys + +# Blender does not put the running script's directory on sys.path. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import _lib as L # noqa: E402 + +# One container. Two of these fill a 6 x 3 x 6 collision volume. +LENGTH = 6.0 +WIDTH = 2.9 +HEIGHT = 3.0 + +CORNER = 0.16 # corner casting cube size +WALL = 0.05 # panel thickness +RIB_DEPTH = 0.045 # how far corrugation stands proud +RIB_PITCH = 0.30 # rib repeat along the length + + +def build(seed: int = 7) -> None: + L.reset_scene() + rng = L.rng(seed) + + body = L.new_bmesh() + steel = L.new_bmesh() + + half_l = LENGTH / 2 + half_w = WIDTH / 2 + + # ---- corrugated long walls (run along Z, normal along X) -------------- + inner_h = HEIGHT - 2 * CORNER + count = int(LENGTH / RIB_PITCH) + start = -(count * RIB_PITCH) / 2 + + for side in (-1, 1): + x = side * half_w + for i in range(count): + z = start + (i + 0.5) * RIB_PITCH + proud = RIB_DEPTH if i % 2 == 0 else 0.0 + jitter = rng.uniform(-0.003, 0.003) + L.add_box( + body, + centre=(x + side * (proud / 2 + jitter), z, HEIGHT / 2), + size=(WALL + proud, RIB_PITCH * 0.94, inner_h), + ) + # Top and bottom rails. + for zc in (CORNER / 2, HEIGHT - CORNER / 2): + L.add_box(steel, centre=(x, 0.0, zc), size=(WALL + RIB_DEPTH * 1.2, LENGTH, CORNER)) + + # ---- roof: shallow ribs running across the width ---------------------- + roof_ribs = int(LENGTH / 0.5) + roof_start = -(roof_ribs * 0.5) / 2 + for i in range(roof_ribs): + z = roof_start + (i + 0.5) * 0.5 + proud = 0.03 if i % 2 == 0 else 0.0 + L.add_box( + body, + centre=(0.0, z, HEIGHT - CORNER / 2 + proud / 2), + size=(WIDTH - 2 * CORNER, 0.47, WALL + proud), + ) + + # ---- floor slab ------------------------------------------------------- + L.add_box(steel, centre=(0.0, 0.0, CORNER / 2), size=(WIDTH - 0.02, LENGTH, CORNER)) + + # ---- blank end (front) ------------------------------------------------ + for i in range(int(WIDTH / 0.28)): + offset = -WIDTH / 2 + (i + 0.5) * 0.28 + proud = 0.035 if i % 2 == 0 else 0.0 + L.add_box( + body, + centre=(offset, -half_l + WALL / 2 - proud / 2, HEIGHT / 2), + size=(0.26, WALL + proud, inner_h), + ) + + # ---- door end (back): two doors, hinges, locking rods ----------------- + door_w = (WIDTH - 2 * CORNER) / 2 + for side in (-1, 1): + cx = side * door_w / 2 + # Door leaf, slightly recessed so it reads as a separate panel. + L.add_box( + steel, + centre=(cx, half_l - WALL / 2, HEIGHT / 2), + size=(door_w - 0.02, WALL, inner_h), + ) + # Two locking rods per leaf, full height. + for rod in (-1, 1): + L.add_cylinder( + steel, + centre=(cx + rod * door_w * 0.28, half_l + 0.03, HEIGHT / 2), + radius=0.022, + height=inner_h - 0.1, + segments=6, + ) + # Handle at chest height. + L.add_box( + steel, + centre=(cx + rod * door_w * 0.28, half_l + 0.06, HEIGHT * 0.45), + size=(0.05, 0.11, 0.22), + ) + # Hinges on the outboard edge. + for hz in (HEIGHT * 0.18, HEIGHT * 0.5, HEIGHT * 0.82): + L.add_box( + steel, + centre=(side * (WIDTH / 2 - CORNER * 0.7), half_l + 0.02, hz), + size=(0.1, 0.09, 0.16), + ) + + # ---- corner castings -------------------------------------------------- + for sx in (-1, 1): + for sz in (-1, 1): + for sy in (0, 1): + L.add_box( + steel, + centre=( + sx * (half_w - CORNER / 2 + 0.01), + sz * (half_l - CORNER / 2), + CORNER / 2 + sy * (HEIGHT - CORNER), + ), + size=(CORNER, CORNER, CORNER), + ) + + # Bevel only the hardware, not the corrugation. Bevelling ~40 rib boxes + # quadrupled the triangle count for highlights that the normal map already + # provides; the castings and door furniture are what the eye actually + # tracks, so they keep their edge highlights. + L.bevel(steel, width=0.01) + + L.finish_object( + "container", + [L.Part(body, L.MAT_PAINT_RED), L.Part(steel, L.MAT_STEEL)], + ) + + # Collision proxy: the whole box. The engine uses the server collision map, + # but a correct COL_ hull keeps the GLB self-describing (CLAUDE.md). + L.add_collider("container", (0.0, 0.0, HEIGHT / 2), (WIDTH, LENGTH, HEIGHT)) + + L.export_glb(L.output_path("container.glb")) + + +build() diff --git a/tools/art/blender/preview.py b/tools/art/blender/preview.py new file mode 100644 index 0000000..a1b82f4 --- /dev/null +++ b/tools/art/blender/preview.py @@ -0,0 +1,160 @@ +""" +Asset preview renderer. + +CLAUDE.md requires every asset to be previewed and validated. A triangle count +tells you nothing about whether a model actually looks like the thing it is +supposed to be, so this loads a built GLB and renders a hero shot of it. + +Cycles on CPU rather than EEVEE: EEVEE needs a real GL context, which a +headless build box does not have. Sample count is deliberately low — this is a +"did the geometry come out right" check, not a beauty render. + +Usage: + blender --background --factory-startup --python preview.py -- \ + --glb build/models/container.glb --out build/previews/container.png +""" + +from __future__ import annotations + +import math +import os +import sys + +import bpy +from mathutils import Vector + + +def arg(name: str, fallback: str | None = None) -> str | None: + argv = sys.argv + return argv[argv.index(name) + 1] if name in argv else fallback + + +def scene_bounds(): + """World-space bounding box of every mesh, used to frame the camera.""" + lo = Vector((1e9, 1e9, 1e9)) + hi = Vector((-1e9, -1e9, -1e9)) + found = False + for obj in bpy.data.objects: + if obj.type != "MESH" or obj.name.startswith("COL_"): + continue + found = True + for corner in obj.bound_box: + world = obj.matrix_world @ Vector(corner) + lo = Vector((min(lo[i], world[i]) for i in range(3))) + hi = Vector((max(hi[i], world[i]) for i in range(3))) + if not found: + raise SystemExit("preview: no visible mesh in GLB") + return lo, hi + + +def main() -> None: + glb = arg("--glb") + out = arg("--out") + if not glb or not out: + raise SystemExit("preview: --glb and --out are required") + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.gltf(filepath=glb) + + # Colliders are structural, not visual — hide them so they do not obscure + # the model being reviewed. + for obj in bpy.data.objects: + if obj.name.startswith("COL_"): + obj.hide_render = True + + lo, hi = scene_bounds() + centre = (lo + hi) / 2 + radius = max((hi - lo).length / 2, 0.5) + + # ---- ground plane so the model has something to sit on and shadow onto + bpy.ops.mesh.primitive_plane_add(size=radius * 20, location=(centre.x, centre.y, lo.z)) + ground = bpy.context.active_object + ground_mat = bpy.data.materials.new("preview_ground") + ground_mat.use_nodes = True + ground_mat.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = ( + 0.05, + 0.055, + 0.065, + 1.0, + ) + ground_mat.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.7 + ground.data.materials.append(ground_mat) + + # ---- three-point-ish lighting, warm key / cool fill, matching the game + # Energies scale with radius squared, not radius: these lights are placed a + # multiple of `radius` away, and irradiance falls off with distance + # squared. Scaling linearly blows out every large asset. + key = bpy.data.lights.new("key", type="AREA") + key.energy = 130 * radius * radius + key.size = radius * 3 + key.color = (1.0, 0.82, 0.6) + key_obj = bpy.data.objects.new("key", key) + key_obj.location = centre + Vector((radius * 2.2, -radius * 2.4, radius * 2.6)) + bpy.context.collection.objects.link(key_obj) + + fill = bpy.data.lights.new("fill", type="AREA") + fill.energy = 38 * radius * radius + fill.size = radius * 4 + fill.color = (0.55, 0.72, 1.0) + fill_obj = bpy.data.objects.new("fill", fill) + fill_obj.location = centre + Vector((-radius * 2.8, -radius * 1.6, radius * 1.4)) + bpy.context.collection.objects.link(fill_obj) + + rim = bpy.data.lights.new("rim", type="AREA") + rim.energy = 70 * radius * radius + rim.size = radius * 2 + rim.color = (0.6, 0.8, 1.0) + rim_obj = bpy.data.objects.new("rim", rim) + rim_obj.location = centre + Vector((-radius * 1.2, radius * 3.0, radius * 1.8)) + bpy.context.collection.objects.link(rim_obj) + + for light in (key_obj, fill_obj, rim_obj): + track = light.constraints.new("TRACK_TO") + empty = bpy.data.objects.new(f"aim_{light.name}", None) + empty.location = centre + bpy.context.collection.objects.link(empty) + track.target = empty + + # ---- camera: three-quarter view, the angle that shows form best + cam_data = bpy.data.cameras.new("preview") + cam_data.lens = 60 + cam = bpy.data.objects.new("preview", cam_data) + angle = math.radians(38) + cam.location = centre + Vector( + ( + math.cos(angle) * radius * 3.1, + math.sin(angle) * radius * 3.1 + radius * 1.4, + radius * 1.35, + ) + ) + bpy.context.collection.objects.link(cam) + aim = bpy.data.objects.new("aim_cam", None) + aim.location = centre + bpy.context.collection.objects.link(aim) + cam.constraints.new("TRACK_TO").target = aim + bpy.context.scene.camera = cam + + # ---- world: dark, slightly blue, like the yard at night + world = bpy.data.worlds.new("preview") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.03, 0.05, 1.0) + world.node_tree.nodes["Background"].inputs["Strength"].default_value = 1.0 + bpy.context.scene.world = world + + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = int(arg("--samples", "48")) + scene.cycles.use_denoising = True + scene.render.resolution_x = int(arg("--width", "900")) + scene.render.resolution_y = int(arg("--height", "640")) + scene.render.image_settings.file_format = "PNG" + scene.view_settings.view_transform = "AgX" + scene.render.filepath = out + + os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) + bpy.ops.render.render(write_still=True) + print(f"PREVIEW {out}") + + +main() diff --git a/tools/art/blender/weapon.py b/tools/art/blender/weapon.py new file mode 100644 index 0000000..ea9bc73 --- /dev/null +++ b/tools/art/blender/weapon.py @@ -0,0 +1,130 @@ +""" +Carbine — the V1 primary weapon. + +In a first-person game the weapon is the single most-viewed object: it occupies +the lower third of the screen every frame of every match. The game currently +renders nothing in the player's hands at all, which is a large part of why the +build reads as unfinished. + +Two uses, one mesh: + + * **Viewmodel** — held in front of the camera. This is why the detail budget + is spent on the parts that face the player: the receiver's right side, the + magazine, the optic and the charging handle. + * **World model** — attached to `SOCKET_WEAPON` on the character. At that + distance only the silhouette survives, and the same mesh serves. + +`SOCKET_MUZZLE` is mandatory for every weapon (CLAUDE.md). The engine spawns +muzzle flash and tracer origins there. Note that it is a *presentation* anchor +only: the authoritative shot origin is derived from the server's player +position, never from this transform, so a tampered client cannot shoot from +somewhere else. + +Oriented with the barrel down -Y (Blender forward) and the sight line along Z +so the engine can parent it to the camera without a correction rotation. +""" + +from __future__ import annotations + +import math +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import _lib as L # noqa: E402 + +# Overall length, breech to muzzle. A real short-barrelled carbine is ~0.75 m; +# matching that keeps the viewmodel from feeling like a toy. +LENGTH = 0.74 + + +def build(seed: int = 41) -> None: + L.reset_scene() + + body = L.new_bmesh() # receiver, furniture — dark polymer + metal = L.new_bmesh() # barrel, bolt, hardware + + # ---- receiver -------------------------------------------------------- + L.add_box(body, centre=(0.0, 0.0, 0.0), size=(0.055, 0.30, 0.085)) + # Upper rail: a flat top with a repeating rail profile reads instantly as + # a modern carbine even in silhouette. + L.add_box(metal, centre=(0.0, -0.02, 0.055), size=(0.042, 0.30, 0.022)) + for i in range(9): + L.add_box( + metal, + centre=(0.0, -0.15 + i * 0.033, 0.070), + size=(0.046, 0.016, 0.012), + ) + + # Ejection port and forward assist on the right side. + L.add_box(metal, centre=(0.031, -0.03, 0.012), size=(0.008, 0.085, 0.045)) + L.add_cylinder(metal, centre=(0.032, 0.04, 0.02), radius=0.012, height=0.02, segments=8, axis="X") + # Charging handle at the rear. + L.add_box(metal, centre=(0.0, 0.145, 0.048), size=(0.07, 0.045, 0.016)) + + # ---- barrel and handguard ------------------------------------------- + L.add_cylinder(metal, centre=(0.0, -0.30, 0.008), radius=0.0105, height=0.30, segments=12, axis="Y") + # Handguard with vent slots. + L.add_box(body, centre=(0.0, -0.27, 0.005), size=(0.048, 0.24, 0.052)) + for i in range(5): + for sx in (-1, 1): + L.add_box( + body, + centre=(sx * 0.025, -0.35 + i * 0.036, 0.005), + size=(0.006, 0.020, 0.030), + ) + # Gas block and front sight base. + L.add_box(metal, centre=(0.0, -0.40, 0.020), size=(0.030, 0.038, 0.042)) + # Muzzle device: a slotted brake, the last thing on the barrel. + L.add_cylinder(metal, centre=(0.0, -0.455, 0.008), radius=0.017, height=0.055, segments=12, axis="Y") + for i in range(3): + L.add_box(metal, centre=(0.0, -0.45 + i * 0.016, 0.026), size=(0.030, 0.006, 0.018)) + + # ---- magazine -------------------------------------------------------- + # Curved by stacking three slightly rotated segments — a straight box reads + # as a placeholder, and the curve is visible in the viewmodel. + for i, (dy, dz, rot) in enumerate( + ((0.0, -0.085, 0.0), (0.012, -0.155, 0.06), (0.030, -0.220, 0.13)) + ): + L.add_box(body, centre=(0.0, dy, dz), size=(0.032, 0.075 - i * 0.004, 0.075)) + L.add_box(body, centre=(0.030, -0.255, 0.0), size=(0.036, 0.080, 0.016)) + + # ---- grip and stock -------------------------------------------------- + L.add_box(body, centre=(0.0, 0.075, -0.105), size=(0.038, 0.062, 0.14)) + L.add_box(metal, centre=(0.0, 0.045, -0.032), size=(0.022, 0.055, 0.020)) # trigger guard + L.add_box(metal, centre=(0.0, 0.035, -0.045), size=(0.010, 0.024, 0.028)) # trigger + + # Collapsible stock: buffer tube plus a cheek piece and butt plate. + L.add_cylinder(metal, centre=(0.0, 0.235, 0.012), radius=0.019, height=0.16, segments=10, axis="Y") + L.add_box(body, centre=(0.0, 0.255, 0.0), size=(0.046, 0.11, 0.070)) + L.add_box(body, centre=(0.0, 0.315, -0.010), size=(0.050, 0.028, 0.105)) # butt plate + + # ---- optic ----------------------------------------------------------- + L.add_box(metal, centre=(0.0, -0.05, 0.088), size=(0.034, 0.010, 0.030)) # mount + L.add_box(metal, centre=(0.0, 0.02, 0.088), size=(0.034, 0.010, 0.030)) + L.add_cylinder(metal, centre=(0.0, -0.015, 0.108), radius=0.021, height=0.085, segments=12, axis="Y") + L.add_cylinder(metal, centre=(0.0, -0.060, 0.108), radius=0.024, height=0.012, segments=12, axis="Y") + + L.bevel(body, width=0.0025) + L.bevel(metal, width=0.002) + + L.finish_object( + "carbine", + [L.Part(body, L.MAT_RUBBER), L.Part(metal, L.MAT_STEEL)], + shade_smooth_angle=math.radians(35), + ) + + # Mandatory for every weapon (CLAUDE.md). Sits just ahead of the muzzle + # device, pointing down -Y with the barrel. + L.add_socket("MUZZLE", (0.0, -0.492, 0.008)) + # Where a spent case leaves the ejection port. + L.add_socket("EJECT", (0.036, -0.03, 0.012)) + # Aim-down-sights anchor: the centre of the optic's rear lens. + L.add_socket("SIGHT", (0.0, -0.060, 0.108)) + + L.export_glb(L.output_path("carbine.glb")) + _ = seed + + +build() diff --git a/tools/art/blender/yard.py b/tools/art/blender/yard.py new file mode 100644 index 0000000..b383850 --- /dev/null +++ b/tools/art/blender/yard.py @@ -0,0 +1,479 @@ +""" +Structural geometry for ARDAVAN YARD. + +Every piece here is sized against a collision volume in +`packages/multiplayer-sim/src/map.ts`. That file is the authority: if a volume +moves, the art follows it, never the other way round. The project rule is that +what you see is what you collide with, so a prop that is prettier but the wrong +size is a bug, not a style choice. + +Volumes this file serves (x, y-up, z in metres): + + tank 8 x 8 x 12 east lane storage vessels + deck 8 x 0.4 x 4 catwalk / gantry decking, tiled along z + pipe_rack 4 x 4 x 5 west lane pipe run, tiled along z + wall 2 x 12 x 6 perimeter, tiled along its long axis + hardpoint 12 x 2.6 x 8 central objective block + stair 2 x 1.5 x 4 ramp steps + lamp_mast - 9 m mast, placed freely + +Run all of them with one Blender start: + blender --background --factory-startup --python yard.py -- --output +""" + +from __future__ import annotations + +import math +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import _lib as L # noqa: E402 + + +# --------------------------------------------------------------------- tank + + +def build_tank(seed: int = 21) -> None: + """ + Horizontal pressure vessel on saddle supports. + + The collision volume is 8 x 8 x 12, which no vertical cylinder fills + convincingly. A horizontal vessel of diameter 8 running the 12 m length + fills it almost exactly, and reads instantly as refinery plant. The corners + the cylinder cannot reach are filled with a walkway, ladder and pipework so + the silhouette does not obviously fall short of its own collision box. + """ + L.reset_scene() + rng = L.rng(seed) + + radius = 4.0 + length = 12.0 + body = L.new_bmesh() + steel = L.new_bmesh() + + # Shell, lying along Y, centred at Z = radius so it sits in the volume. + L.add_cylinder( + body, centre=(0.0, 0.0, radius), radius=radius, height=length, segments=32, axis="Y" + ) + + # Dished heads. A pressure vessel never ends in a flat disc, and the flat + # version read as a cut-off tube. Approximated with two stepped rings + # rather than a real torispherical surface — at gameplay distance the + # silhouette is all that matters and this costs a fraction of the triangles. + for sy in (-1, 1): + for step, (r_scale, offset) in enumerate(((0.92, 0.30), (0.70, 0.52), (0.38, 0.66))): + L.add_cylinder( + body, + centre=(0.0, sy * (length / 2 + offset), radius), + radius=radius * r_scale, + height=0.26 if step < 2 else 0.2, + segments=32, + axis="Y", + ) + + # Reinforcing bands. Real vessels are rolled from plate and banded at the + # seams; the bands also break up a very large untextured cylinder. + for i in range(5): + y = -length / 2 + (i + 0.5) * (length / 5) + L.add_cylinder( + steel, centre=(0.0, y, radius), radius=radius + 0.06, height=0.22, segments=32, axis="Y" + ) + + # Saddle supports. + for sy in (-1, 1): + y = sy * length * 0.3 + L.add_box(steel, centre=(0.0, y, 0.45), size=(radius * 1.7, 0.6, 0.9)) + for sx in (-1, 1): + L.add_box(steel, centre=(sx * radius * 0.7, y, 1.15), size=(0.3, 0.55, 0.7)) + + # Top walkway with kick rails, spanning the length. + L.add_box(steel, centre=(0.0, 0.0, radius * 2 - 0.05), size=(1.4, length * 0.92, 0.1)) + for sx in (-1, 1): + for i in range(7): + y = -length * 0.42 + i * (length * 0.84 / 6) + L.add_cylinder( + steel, + centre=(sx * 0.68, y, radius * 2 + 0.5), + radius=0.035, + height=1.0, + segments=6, + ) + L.add_cylinder( + steel, + centre=(sx * 0.68, 0.0, radius * 2 + 0.98), + radius=0.045, + height=length * 0.86, + segments=6, + axis="Y", + ) + + # End-cap nozzles and a manway, so the ends are not bare discs. + for sy in (-1, 1): + L.add_cylinder( + steel, + centre=(0.0, sy * (length / 2 + 0.2), radius), + radius=0.55, + height=0.4, + segments=16, + axis="Y", + ) + L.add_cylinder( + steel, + centre=(radius * 0.45, sy * (length / 2 + 0.15), radius * 0.6), + radius=0.22, + height=0.3, + segments=10, + axis="Y", + ) + + # Access ladder up one side. + for i in range(14): + z = 0.4 + i * 0.55 + if z > radius * 2 - 0.2: + break + L.add_cylinder( + steel, centre=(-radius - 0.25, length * 0.36, z), radius=0.025, height=0.6, + segments=6, axis="X", + ) + for sy in (-0.12, 0.12): + L.add_cylinder( + steel, + centre=(-radius - 0.25, length * 0.36 + sy * 4, radius), + radius=0.03, + height=radius * 2 - 0.3, + segments=6, + ) + + # Pipework running off the vessel, filling the volume's dead corners. + for i, (px, pz) in enumerate(((radius * 0.8, 0.9), (radius * 0.86, 1.7))): + L.add_cylinder( + steel, centre=(px, 0.0, pz), radius=0.16 + i * 0.04, height=length * 1.0, + segments=12, axis="Y", + ) + for j in range(3): + y = -length * 0.35 + j * (length * 0.35) + L.add_cylinder( + steel, centre=(px, y, pz), radius=0.24 + i * 0.04, height=0.12, + segments=12, axis="Y", + ) + + L.bevel(steel, width=0.012) + + L.finish_object( + "tank", + [L.Part(body, L.MAT_RUST), L.Part(steel, L.MAT_STEEL)], + shade_smooth_angle=math.radians(40), + ) + L.add_collider("tank", (0.0, 0.0, 4.0), (8.0, 12.0, 8.0)) + L.export_glb(L.output_path("tank.glb")) + _ = rng + + +# --------------------------------------------------------------------- deck + + +def build_deck(seed: int = 22) -> None: + """ + One 4 m section of catwalk / gantry decking, 8 m wide. + + Tiled along z by the engine to cover the 40 m gantry and the 68 m catwalk. + A section rather than a single long mesh so the two runs share one asset, + and so a partially-occluded run still culls per section. + """ + L.reset_scene() + + width = 8.0 + span = 4.0 + thickness = 0.4 + + grating = L.new_bmesh() + steel = L.new_bmesh() + + # Walking surface. Modelled as a thin slab; the holes are in the texture, + # which is far cheaper than real perforated geometry and reads identically + # from above at standing height. + L.add_box(grating, centre=(0.0, 0.0, thickness - 0.03), size=(width - 0.1, span, 0.06)) + + # Longitudinal stringers and cross beams carry the deck. + for sx in (-1, 1): + L.add_box(steel, centre=(sx * (width / 2 - 0.12), 0.0, thickness / 2), size=(0.16, span, thickness)) + L.add_box(steel, centre=(0.0, 0.0, thickness / 2), size=(0.14, span, thickness * 0.9)) + for sy in (-1, 1): + L.add_box(steel, centre=(0.0, sy * (span / 2 - 0.08), thickness / 2), size=(width - 0.2, 0.14, thickness * 0.8)) + + # Handrails: top rail, mid rail, toe board, stanchions. Handrails are the + # single strongest cue that a surface is walkable. + for sx in (-1, 1): + x = sx * (width / 2 - 0.08) + L.add_box(steel, centre=(x, 0.0, thickness + 0.07), size=(0.04, span, 0.14)) # toe board + for i in range(3): + y = -span / 2 + (i + 0.5) * (span / 3) + L.add_cylinder(steel, centre=(x, y, thickness + 0.55), radius=0.032, height=1.1, segments=6) + for z in (thickness + 0.52, thickness + 1.05): + L.add_cylinder(steel, centre=(x, 0.0, z), radius=0.032, height=span, segments=6, axis="Y") + + L.bevel(steel, width=0.008) + + L.finish_object("deck", [L.Part(grating, L.MAT_GRATING), L.Part(steel, L.MAT_STEEL)]) + L.add_collider("deck", (0.0, 0.0, thickness / 2), (width, span, thickness)) + L.export_glb(L.output_path("deck.glb")) + _ = seed + + +# ---------------------------------------------------------------- pipe rack + + +def build_pipe_rack(seed: int = 23) -> None: + """One 5 m section of the west-lane pipe rack: 4 m wide, 4 m tall.""" + L.reset_scene() + rng = L.rng(seed) + + width = 4.0 + span = 5.0 + height = 4.0 + + steel = L.new_bmesh() + pipes = L.new_bmesh() + + # Portal frame: two columns and a cap beam at each end of the section. + for sy in (-1, 1): + y = sy * (span / 2 - 0.15) + for sx in (-1, 1): + L.add_box(steel, centre=(sx * (width / 2 - 0.2), y, height / 2), size=(0.28, 0.28, height)) + L.add_box(steel, centre=(0.0, y, height - 0.15), size=(width - 0.1, 0.24, 0.3)) + L.add_box(steel, centre=(0.0, y, height * 0.55), size=(width - 0.5, 0.18, 0.22)) + # Knee braces. + for sx in (-1, 1): + L.add_box( + steel, + centre=(sx * (width / 2 - 0.62), y, height - 0.62), + size=(0.9, 0.16, 0.16), + rotation_z=0.0, + ) + + # The pipes themselves, at two levels and varied diameters. + levels = ((height - 0.42, 0.30), (height - 0.42, 0.18), (height * 0.55 - 0.2, 0.24)) + offsets = (-1.2, -0.45, 0.35, 1.15) + for i, x in enumerate(offsets): + z, r = levels[i % len(levels)] + radius = r * (0.85 + rng.random() * 0.3) + L.add_cylinder(pipes, centre=(x, 0.0, z + radius), radius=radius, height=span, segments=20, axis="Y") + # No flanges. Two attempts at collar rings both rendered as spheres: + # smooth-by-angle averages normals across the short ring and its parent + # pipe, turning a 16%-proud collar into a ball. The pipes are held by + # visible clamps at the frames instead, which is both cheaper and a + # clearer read of how the run is supported. + for sy in (-1, 1): + L.add_box( + steel, + centre=(x, sy * (span / 2 - 0.15), z + radius), + size=(radius * 2.3, 0.1, radius * 0.5), + ) + + L.bevel(steel, width=0.01) + + L.finish_object( + "pipe_rack", + [L.Part(steel, L.MAT_STEEL), L.Part(pipes, L.MAT_RUST)], + shade_smooth_angle=math.radians(40), + ) + L.add_collider("pipe_rack", (0.0, 0.0, height / 2), (width, span, height)) + L.export_glb(L.output_path("pipe_rack.glb")) + + +# --------------------------------------------------------------------- wall + + +def build_wall(seed: int = 24) -> None: + """ + One 6 m panel of the 12 m perimeter wall. + + Precast concrete panels with pilasters and a capping beam. Deliberately + plain: the perimeter is a boundary, and detail here would pull the eye away + from the lanes where the game is actually played. + """ + L.reset_scene() + rng = L.rng(seed) + + thickness = 2.0 + height = 12.0 + span = 6.0 + + concrete = L.new_bmesh() + steel = L.new_bmesh() + + L.add_box(concrete, centre=(0.0, 0.0, height / 2), size=(thickness * 0.62, span, height)) + + # Pilasters at the panel joints give the run a rhythm at yard distance. + for sy in (-1, 1): + L.add_box(concrete, centre=(0.0, sy * span / 2, height / 2), size=(thickness, 0.5, height)) + + # Capping beam and a recessed band, so the wall is not a featureless slab. + L.add_box(concrete, centre=(0.0, 0.0, height - 0.3), size=(thickness * 0.8, span, 0.6)) + L.add_box(concrete, centre=(0.0, 0.0, height * 0.62), size=(thickness * 0.5, span, 0.35)) + L.add_box(concrete, centre=(0.0, 0.0, 0.35), size=(thickness * 0.78, span, 0.7)) + + # Razor-wire brackets along the top: a strong silhouette read against the + # false-dawn sky, which is where the perimeter is most often seen. + for i in range(3): + y = -span / 2 + (i + 0.5) * (span / 3) + L.add_cylinder(steel, centre=(0.0, y, height + 0.42), radius=0.035, height=0.85, segments=6) + L.add_cylinder( + steel, centre=(0.28, y, height + 0.72), radius=0.03, height=0.5, segments=6, axis="X" + ) + for z in (height + 0.55, height + 0.8): + L.add_cylinder(steel, centre=(0.16, 0.0, z), radius=0.02, height=span, segments=5, axis="Y") + + L.bevel(steel, width=0.008) + L.finish_object("wall", [L.Part(concrete, L.MAT_CONCRETE), L.Part(steel, L.MAT_STEEL)]) + L.add_collider("wall", (0.0, 0.0, height / 2), (thickness, span, height)) + L.export_glb(L.output_path("wall.glb")) + _ = rng + + +# ---------------------------------------------------------------- hardpoint + + +def build_hardpoint(seed: int = 25) -> None: + """The central objective block: 12 x 2.6 x 8, a low concrete plinth.""" + L.reset_scene() + + width, height, depth = 12.0, 2.6, 8.0 + concrete = L.new_bmesh() + steel = L.new_bmesh() + + L.add_box(concrete, centre=(0.0, 0.0, height / 2), size=(width, depth, height)) + # Chamfered top edge and a kerb, so it reads as cast concrete. + L.add_box(concrete, centre=(0.0, 0.0, height - 0.08), size=(width - 0.5, depth - 0.5, 0.3)) + L.add_box(concrete, centre=(0.0, 0.0, 0.2), size=(width + 0.3, depth + 0.3, 0.4)) + + # Bollards around the edge and a handrail on one side: cover cues that tell + # a player where they can and cannot be shot from. + for i in range(5): + x = -width / 2 + (i + 0.5) * (width / 5) + L.add_cylinder(steel, centre=(x, depth / 2 - 0.4, height + 0.45), radius=0.09, height=0.9, segments=8) + L.add_cylinder( + steel, centre=(0.0, depth / 2 - 0.4, height + 0.86), radius=0.05, height=width * 0.8, + segments=8, axis="X", + ) + + L.bevel(steel, width=0.01) + L.finish_object("hardpoint", [L.Part(concrete, L.MAT_CONCRETE), L.Part(steel, L.MAT_STEEL)]) + L.add_collider("hardpoint", (0.0, 0.0, height / 2), (width, depth, height)) + L.export_glb(L.output_path("hardpoint.glb")) + _ = seed + + +# -------------------------------------------------------------------- stair + + +def build_stair(seed: int = 26) -> None: + """One 2 x 1.5 x 4 ramp step, matching the stepped access boxes.""" + L.reset_scene() + + width, rise, run = 2.0, 1.5, 4.0 + steel = L.new_bmesh() + grating = L.new_bmesh() + + # Treads climbing the box, rather than a solid wedge: the collision is a + # box, but a visible stair is what tells a player the route is climbable. + steps = 5 + for i in range(steps): + z = (i + 1) * (rise / steps) + y = -run / 2 + (i + 0.5) * (run / steps) + L.add_box(grating, centre=(0.0, y, z - 0.03), size=(width - 0.12, run / steps, 0.06)) + L.add_box(steel, centre=(0.0, y - run / steps / 2, z - rise / steps / 2), size=(width - 0.2, 0.05, rise / steps)) + + # Stringers and a handrail. + for sx in (-1, 1): + x = sx * (width / 2 - 0.06) + L.add_box(steel, centre=(x, 0.0, rise / 2), size=(0.1, run, rise * 0.9)) + for i in range(3): + y = -run / 2 + (i + 0.5) * (run / 3) + z = rise * ((i + 0.5) / 3) + L.add_cylinder(steel, centre=(x, y, z + 0.55), radius=0.03, height=1.1, segments=6) + + L.bevel(steel, width=0.008) + L.finish_object("stair", [L.Part(grating, L.MAT_GRATING), L.Part(steel, L.MAT_STEEL)]) + L.add_collider("stair", (0.0, 0.0, rise / 2), (width, run, rise)) + L.export_glb(L.output_path("stair.glb")) + _ = seed + + +# ---------------------------------------------------------------- lamp mast + + +def build_lamp_mast(seed: int = 27) -> None: + """ + A 9 m sodium lamp mast. + + Eleven of these line the lanes and they are the yard's practical lights, so + the head has to read as a fitting rather than a glowing cube. The emissive + lens is a separate material the engine drives with its glow layer. + """ + L.reset_scene() + + steel = L.new_bmesh() + lens = L.new_bmesh() + + height = 9.0 + + # Tapered mast: a base section and a narrower upper section. + L.add_cylinder(steel, centre=(0.0, 0.0, 0.12), radius=0.34, height=0.24, segments=12) + L.add_cylinder(steel, centre=(0.0, 0.0, height * 0.28), radius=0.16, height=height * 0.56, segments=12) + L.add_cylinder(steel, centre=(0.0, 0.0, height * 0.76), radius=0.12, height=height * 0.42, segments=12) + + # Base flange bolts. + for i in range(6): + a = i * math.tau / 6 + L.add_cylinder( + steel, centre=(math.cos(a) * 0.26, math.sin(a) * 0.26, 0.27), radius=0.028, + height=0.08, segments=6, + ) + + # Bracket arm reaching out, then the lamp head hanging off it. + L.add_cylinder(steel, centre=(0.45, 0.0, height - 0.1), radius=0.07, height=0.9, segments=8, axis="X") + L.add_box(steel, centre=(0.86, 0.0, height - 0.26), size=(0.5, 0.34, 0.16)) + # Housing: a shallow shade over the lens, which is what makes the pool of + # light below read as cast rather than ambient. + L.add_box(steel, centre=(0.86, 0.0, height - 0.36), size=(0.62, 0.44, 0.1)) + L.add_box(lens, centre=(0.86, 0.0, height - 0.45), size=(0.5, 0.34, 0.08)) + + L.bevel(steel, width=0.008) + L.finish_object( + "lamp_mast", + [L.Part(steel, L.MAT_STEEL), L.Part(lens, L.MAT_GLASS_EMISSIVE)], + shade_smooth_angle=math.radians(35), + ) + L.add_collider("lamp_mast", (0.0, 0.0, height / 2), (0.4, 0.4, height)) + # Where the engine attaches the point light, so art and lighting cannot + # drift apart. + L.add_socket("LAMP", (0.86, 0.0, height - 0.5)) + L.export_glb(L.output_path("lamp_mast.glb")) + _ = seed + + +BUILDERS = { + "tank": build_tank, + "deck": build_deck, + "pipe_rack": build_pipe_rack, + "wall": build_wall, + "hardpoint": build_hardpoint, + "stair": build_stair, + "lamp_mast": build_lamp_mast, +} + + +def main() -> None: + only = None + if "--only" in sys.argv: + only = set(sys.argv[sys.argv.index("--only") + 1].split(",")) + for name, fn in BUILDERS.items(): + if only and name not in only: + continue + fn() + + +main() diff --git a/tools/art/build-assets.mjs b/tools/art/build-assets.mjs new file mode 100644 index 0000000..e17e4f8 --- /dev/null +++ b/tools/art/build-assets.mjs @@ -0,0 +1,254 @@ +#!/usr/bin/env node +/** + * Asset build. + * + * Regenerates every model and texture in the game from the generator scripts + * in this directory. Nothing here is hand-authored and nothing is downloaded, + * which is what makes the whole asset set satisfy CLAUDE.md's provenance rule: + * the provenance of any file is "this commit, this script, this seed". + * + * The build is deterministic. Running it twice on the same commit produces the + * same bytes, so `git status` after a rebuild is the regression test — if the + * tree is dirty, a generator picked up an unseeded source of randomness. + * + * Usage: + * node tools/art/build-assets.mjs # models + textures + * node tools/art/build-assets.mjs --previews # ...and render preview PNGs + * node tools/art/build-assets.mjs --textures-only + * node tools/art/build-assets.mjs --models-only + * + * Requires Blender 4.5+ on PATH or in $BLENDER. Blender's bundled Python + * provides numpy for the texture generator, so there is no pip dependency. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = resolve(fileURLToPath(new URL(".", import.meta.url))); +const ROOT = resolve(HERE, "../.."); +const OUT = join(ROOT, "apps/game/public/assets"); +const MODELS_OUT = join(OUT, "models"); +const TEXTURES_OUT = join(OUT, "textures"); +const PREVIEWS_OUT = join(ROOT, "build/asset-previews"); + +const args = process.argv.slice(2); +const has = (flag) => args.includes(flag); + +/** Model generators, in the order they are built. */ +const MODEL_SCRIPTS = ["yard.py", "container.py", "character.py", "weapon.py"]; + +/** Texture resolution. 1024 keeps the whole set near 2 MB as WebP. */ +const TEXTURE_SIZE = 1024; +/** WebP quality. 88 is where this palette stops showing banding in the sky. */ +const WEBP_QUALITY = 88; + +function blenderBinary() { + const explicit = process.env.BLENDER; + if (explicit && existsSync(explicit)) return explicit; + for (const candidate of [ + "blender", + `${process.env.HOME}/.local/bin/blender`, + "/usr/bin/blender", + "/snap/bin/blender", + ]) { + try { + execFileSync(candidate, ["--version"], { stdio: "pipe" }); + return candidate; + } catch { + /* keep looking */ + } + } + throw new Error( + "Blender 4.5+ not found. Install it or set $BLENDER.\n" + + " https://download.blender.org/release/Blender4.5/", + ); +} + +function blenderPython(binary) { + // Blender ships its own Python with numpy. Locating it avoids a pip install + // and guarantees the texture generator runs against the same numpy that + // Blender itself uses. + const root = execFileSync( + binary, + [ + "--background", + "--factory-startup", + "--python-expr", + "import sys;print('PYBIN', sys.executable)", + ], + { encoding: "utf8", stdio: "pipe" }, + ); + const match = root.match(/^PYBIN (.+)$/m); + if (!match) throw new Error("could not locate Blender's bundled Python"); + return match[1].trim(); +} + +function run(binary, argv, label) { + process.stdout.write(` ${label}\n`); + const output = execFileSync(binary, argv, { encoding: "utf8", stdio: "pipe", cwd: HERE }); + for (const line of output.split("\n")) { + if (/^(EXPORTED|TEXTURE|PREVIEW)/.test(line)) process.stdout.write(` ${line}\n`); + } + return output; +} + +function toWebp(pngDir, outDir) { + mkdirSync(outDir, { recursive: true }); + let total = 0; + for (const file of readdirSync(pngDir).sort()) { + if (!file.endsWith(".png")) continue; + const target = join(outDir, file.replace(/\.png$/, ".webp")); + execFileSync( + "ffmpeg", + [ + "-y", + "-loglevel", + "error", + "-i", + join(pngDir, file), + "-quality", + String(WEBP_QUALITY), + target, + ], + { stdio: "pipe" }, + ); + total += statSync(target).size; + } + return total; +} + +function directoryBytes(dir) { + if (!existsSync(dir)) return 0; + return readdirSync(dir).reduce((sum, f) => sum + statSync(join(dir, f)).size, 0); +} + +function main() { + const blender = blenderBinary(); + const version = execFileSync(blender, ["--version"], { encoding: "utf8" }).split("\n")[0].trim(); + console.log(`NIGHTCELL 7 asset build\n ${version}\n`); + + const doModels = !has("--textures-only"); + const doTextures = !has("--models-only"); + + if (doModels) { + console.log("models"); + mkdirSync(MODELS_OUT, { recursive: true }); + for (const script of MODEL_SCRIPTS) { + run( + blender, + [ + "--background", + "--factory-startup", + "--python", + join(HERE, "blender", script), + "--", + "--output", + MODELS_OUT, + ], + script, + ); + } + } + + if (doTextures) { + console.log("\ntextures"); + const python = blenderPython(blender); + const staging = join(ROOT, "build/texture-png"); + rmSync(staging, { recursive: true, force: true }); + mkdirSync(staging, { recursive: true }); + + run( + python, + [join(HERE, "textures/generate.py"), "--out", staging, "--size", String(TEXTURE_SIZE)], + `generate.py (${TEXTURE_SIZE}px)`, + ); + + process.stdout.write(" encoding webp\n"); + const bytes = toWebp(staging, TEXTURES_OUT); + rmSync(staging, { recursive: true, force: true }); + process.stdout.write(` ${(bytes / 1048576).toFixed(2)} MB of WebP\n`); + } + + if (has("--previews")) { + console.log("\npreviews"); + mkdirSync(PREVIEWS_OUT, { recursive: true }); + for (const file of readdirSync(MODELS_OUT).sort()) { + if (!file.endsWith(".glb")) continue; + run( + blender, + [ + "--background", + "--factory-startup", + "--python", + join(HERE, "blender/preview.py"), + "--", + "--glb", + join(MODELS_OUT, file), + "--out", + join(PREVIEWS_OUT, file.replace(/\.glb$/, ".png")), + "--samples", + "40", + ], + file, + ); + } + } + + // -------------------------------------------------------------- manifest + const models = existsSync(MODELS_OUT) + ? readdirSync(MODELS_OUT) + .filter((f) => f.endsWith(".glb")) + .sort() + : []; + const textures = existsSync(TEXTURES_OUT) + ? readdirSync(TEXTURES_OUT) + .filter((f) => f.endsWith(".webp")) + .sort() + : []; + + const modelBytes = directoryBytes(MODELS_OUT); + const textureBytes = directoryBytes(TEXTURES_OUT); + + const commit = (() => { + try { + return execFileSync("git", ["rev-parse", "HEAD"], { cwd: ROOT, encoding: "utf8" }).trim(); + } catch { + return "unknown"; + } + })(); + + writeFileSync( + join(OUT, "manifest.json"), + `${JSON.stringify( + { + generator: "tools/art/build-assets.mjs", + blender: version, + commit, + license: "Original work, © NIGHTCELL 7. No third-party assets.", + source: + "Generated procedurally from the scripts in tools/art. No asset is " + + "downloaded, photographed, traced, or derived from another game.", + textureSize: TEXTURE_SIZE, + webpQuality: WEBP_QUALITY, + models, + textures, + bytes: { models: modelBytes, textures: textureBytes, total: modelBytes + textureBytes }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const total = modelBytes + textureBytes; + console.log( + `\n${models.length} models (${(modelBytes / 1048576).toFixed(2)} MB) + ` + + `${textures.length} textures (${(textureBytes / 1048576).toFixed(2)} MB) ` + + `= ${(total / 1048576).toFixed(2)} MB uncompressed`, + ); + console.log(`manifest -> ${join(OUT, "manifest.json")}`); +} + +main(); diff --git a/tools/art/textures/generate.py b/tools/art/textures/generate.py new file mode 100644 index 0000000..20d07a1 --- /dev/null +++ b/tools/art/textures/generate.py @@ -0,0 +1,624 @@ +#!/usr/bin/env python3 +""" +Procedural PBR texture generator for NIGHTCELL 7. + +Every surface in the game is textured from this file. Nothing is downloaded, +photographed or traced, which is what lets the whole set satisfy CLAUDE.md's +"no public asset without provenance" rule — the provenance is this script plus +a seed. + +Each material produces the three maps a glTF/Babylon PBR workflow needs: + + _albedo.webp base colour, no lighting baked in + _normal.webp tangent-space normal, derived from a height field + _orm.webp R = ambient occlusion, G = roughness, B = metallic + +ORM packing is not an optimisation detail — it is the layout Babylon reads +directly (`useRoughnessFromMetallicTextureGreen` / `...MetallicTextureBlue`), +so three separate greyscale files would cost three texture fetches per pixel +for no benefit. + +Everything is tileable. The noise lattices wrap, so a 4 m tile repeats across a +120 m yard without a visible seam. Run with Blender's bundled Python, which +already has numpy: + + /home/ubuntu/.local/opt/blender/4.5/python/bin/python3.11 generate.py --out +""" + +from __future__ import annotations + +import argparse +import os +import struct +import zlib + +import numpy as np + +RES = 1024 + + +# --------------------------------------------------------------------- noise + + +def _lattice(rng: np.random.Generator, freq: int) -> np.ndarray: + return rng.random((freq, freq), dtype=np.float64) + + +def _smoothstep(t: np.ndarray) -> np.ndarray: + return t * t * (3.0 - 2.0 * t) + + +def value_noise(size: int, freq: int, rng: np.random.Generator) -> np.ndarray: + """ + Tileable bilinear value noise. + + The lattice index wraps with `% freq`, which is what makes the result + seamless — the right edge samples the same lattice column as the left. + """ + grid = _lattice(rng, freq) + + coords = np.arange(size, dtype=np.float64) * freq / size + i0 = np.floor(coords).astype(int) % freq + i1 = (i0 + 1) % freq + frac = _smoothstep(coords - np.floor(coords)) + + # Interpolate rows then columns. + top = grid[np.ix_(i0, i0)] * (1 - frac)[None, :] + grid[np.ix_(i0, i1)] * frac[None, :] + bottom = grid[np.ix_(i1, i0)] * (1 - frac)[None, :] + grid[np.ix_(i1, i1)] * frac[None, :] + return top * (1 - frac)[:, None] + bottom * frac[:, None] + + +def fbm(size: int, rng: np.random.Generator, octaves: int = 6, base_freq: int = 4) -> np.ndarray: + """Fractional Brownian motion — the general-purpose "natural variation" field.""" + total = np.zeros((size, size)) + amplitude = 1.0 + norm = 0.0 + freq = base_freq + for _ in range(octaves): + total += value_noise(size, freq, rng) * amplitude + norm += amplitude + amplitude *= 0.5 + freq *= 2 + return total / norm + + +def ridged(size: int, rng: np.random.Generator, octaves: int = 5, base_freq: int = 4) -> np.ndarray: + """Ridged noise — sharp creases, used for cracks and weld lines.""" + total = np.zeros((size, size)) + amplitude = 1.0 + norm = 0.0 + freq = base_freq + for _ in range(octaves): + n = value_noise(size, freq, rng) + total += (1.0 - np.abs(n * 2 - 1)) ** 2 * amplitude + norm += amplitude + amplitude *= 0.5 + freq *= 2 + return total / norm + + +def worley(size: int, cells: int, rng: np.random.Generator) -> np.ndarray: + """ + Tileable Worley (cellular) noise — distance to the nearest feature point. + + Used for concrete aggregate and rust blotching. Points are jittered inside + a regular grid and neighbours are checked with wraparound, which keeps it + seamless and O(size^2 * 9) instead of O(size^2 * cells^2). + """ + # One jittered feature point per cell, stored as an in-cell fraction so a + # wrapped neighbour can be un-wrapped back into continuous space. + jitter = rng.random((cells, cells, 2)) + + ys, xs = np.meshgrid( + (np.arange(size) + 0.5) / size, (np.arange(size) + 0.5) / size, indexing="ij" + ) + cy = np.floor(ys * cells).astype(int) + cx = np.floor(xs * cells).astype(int) + + best = np.full((size, size), 10.0) + for dy in (-1, 0, 1): + for dx in (-1, 0, 1): + iy = (cy + dy) % cells + ix = (cx + dx) % cells + # Use the unwrapped cell index for the position: a neighbour off the + # left edge must sit at a negative coordinate, not jump to the far + # side, or the distance field creases at the tile seam. + py = ((cy + dy) + jitter[iy, ix, 0]) / cells + px = ((cx + dx) + jitter[iy, ix, 1]) / cells + best = np.minimum(best, np.sqrt((ys - py) ** 2 + (xs - px) ** 2)) + + return best / best.max() + + +def streaks(size: int, rng: np.random.Generator, strength: float = 1.0) -> np.ndarray: + """ + Vertical runoff streaking. + + Weathering is directional: water carries dirt and rust *down*. Isotropic + noise alone always reads as "procedural"; this is what makes a surface look + like it has been rained on. + """ + # Sparse origins — only some columns ever start a run. + seeds = value_noise(size, 96, rng) + seeds = np.clip((seeds - 0.62) * 5.0, 0, 1) + seeds *= np.clip(value_noise(size, 16, rng) * 1.4, 0, 1) + + # Smear downward with decay. Repeating one noise row instead (the naive + # version) gives dead-straight full-height stripes that alias badly and + # read as a rendering fault rather than as weathering. + trail = seeds.copy() + for _ in range(7): + trail = np.maximum(trail, np.roll(trail, 1, axis=0) * 0.86) + for _ in range(3): + trail = np.maximum(trail, np.roll(trail, 4, axis=0) * 0.72) + + # Break the runs up so they fade unevenly, like real dirt. + trail *= 0.55 + 0.45 * fbm(size, rng, octaves=4, base_freq=12) + trail = blur(trail, 1) + + return np.clip(trail * strength, 0, 1) + + +def blur(a: np.ndarray, radius: int = 2) -> np.ndarray: + """Cheap separable box blur with wraparound, for AO and softening.""" + out = a.astype(np.float64) + k = radius * 2 + 1 + acc = np.zeros_like(out) + for shift in range(-radius, radius + 1): + acc += np.roll(out, shift, axis=0) + out = acc / k + acc = np.zeros_like(out) + for shift in range(-radius, radius + 1): + acc += np.roll(out, shift, axis=1) + return acc / k + + +def normalise(a: np.ndarray) -> np.ndarray: + lo, hi = a.min(), a.max() + return (a - lo) / (hi - lo) if hi > lo else np.zeros_like(a) + + +# ------------------------------------------------------------- map synthesis + + +def height_to_normal(height: np.ndarray, strength: float = 1.0) -> np.ndarray: + """ + Tangent-space normal from a height field via central differences. + + Rolling rather than padding keeps the normal map tileable in step with the + height it came from. + """ + # Z stays at 1 and the gradient is scaled against it. Driving Z toward zero + # instead (the obvious-looking `1/strength`) tips every normal almost flat + # to the surface, which is what turned concrete into bubble wrap. + # + # The gain is divided by the resolution ratio so a 512 and a 1024 version of + # the same material have the same apparent relief rather than the larger one + # looking smoother. + gain = 18.0 * strength / (height.shape[0] / 512.0) + + # Soften by one texel first. Central differences on a raw height field + # amplify single-texel noise into a normal map that sparkles under a moving + # camera — the speckle is far more visible in motion than in a still. + height = blur(height, 1) + + dx = (np.roll(height, -1, axis=1) - np.roll(height, 1, axis=1)) * 0.5 + dy = (np.roll(height, -1, axis=0) - np.roll(height, 1, axis=0)) * 0.5 + + nx = -dx * gain + ny = -dy * gain + nz = np.ones_like(height) + + length = np.sqrt(nx**2 + ny**2 + nz**2) + nx, ny, nz = nx / length, ny / length, nz / length + + return np.stack([nx * 0.5 + 0.5, ny * 0.5 + 0.5, nz * 0.5 + 0.5], axis=-1) + + +def cavity_ao(height: np.ndarray, radius: int = 6) -> np.ndarray: + """ + Approximate AO: how far below its local average a texel sits. + + Not a ray-traced occlusion pass, but for tiling detail it is the part of AO + that actually reads — dirt and shadow gathering in the low spots. + """ + # Soften first, for the same reason the normal pass does: AO built from a + # raw height field speckles, and speckled AO reads as dirt-coloured noise. + height = blur(height, 1) + local = blur(height, radius) + ao = np.clip(1.0 - (local - height) * 6.0, 0.0, 1.0) + return np.clip(ao * 0.85 + 0.15, 0, 1) + + +# ------------------------------------------------------------------ PNG I/O + + +def write_png(path: str, rgb: np.ndarray) -> None: + """Minimal PNG writer (stdlib only) — avoids a Pillow dependency.""" + data = np.clip(rgb * 255.0 + 0.5, 0, 255).astype(np.uint8) + if data.ndim == 2: + data = np.stack([data] * 3, axis=-1) + height, width, _ = data.shape + + raw = b"".join(b"\x00" + data[y].tobytes() for y in range(height)) + + def chunk(tag: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + tag + + payload + + struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF) + ) + + png = ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(path, "wb") as handle: + handle.write(png) + + +# ----------------------------------------------------------------- materials + + +def mat_concrete(size: int, seed: int) -> dict: + rng = np.random.default_rng(seed) + + # Aggregate is the exposed stone in the mix. It must be fine and low + # amplitude: at 48 cells it dominated the relief and read as bubble wrap. + aggregate = 1.0 - worley(size, 150, rng) + pits = np.clip(worley(size, 60, rng) - 0.55, 0, 1) * 2.0 + mottle = fbm(size, rng, octaves=6, base_freq=3) + # Capped well below texel frequency. At base_freq 64 the top octave landed + # at 512 — per-texel noise, which shows up as speckle in both the normal + # and the AO channel. + grain = fbm(size, rng, octaves=3, base_freq=20) + grime = fbm(size, rng, octaves=4, base_freq=8) + cracks = np.clip(ridged(size, rng, octaves=4, base_freq=5) - 0.70, 0, 1) * 4.0 + + height = ( + normalise(mottle * 0.55 + grain * 0.2 + aggregate * 0.25) - pits * 0.35 - cracks * 0.6 + ) + + # Wider tonal spread than the first pass, which came out nearly flat. + base = 0.15 + mottle * 0.14 + aggregate * 0.06 + grain * 0.04 + base = base - cracks * 0.10 - pits * 0.05 + # Concrete is faintly warm; a pure grey reads as untextured plastic. + albedo = np.stack([base * 1.03, base, base * 0.94], axis=-1) + albedo = np.clip(albedo - grime[..., None] * 0.05, 0, 1) + + roughness = np.clip(0.80 + mottle * 0.14 - aggregate * 0.06 + cracks * 0.1, 0.45, 1.0) + metallic = np.zeros((size, size)) + ao = cavity_ao(height) + + return { + "albedo": albedo, + "normal": height_to_normal(height, strength=1.6), + "orm": np.stack([ao, roughness, metallic], axis=-1), + } + + +def brushed(size: int, rng: np.random.Generator, along: int = 1) -> np.ndarray: + """ + Anisotropic brushed-metal grain. + + Brushing produces fine lines *along* one axis, which means the value must + vary quickly across that axis and barely at all along it. Isotropic noise — + what the first version used — just looks like grey static and is the reason + the steel read as flat untextured plastic. + """ + across = 0 if along == 1 else 1 + lines = rng.random(size) + # Smooth slightly across the grain so the lines have width. + lines = (lines + np.roll(lines, 1) + np.roll(lines, -1)) / 3.0 + + grain = np.repeat(lines[:, None], size, axis=1) if across == 0 else np.repeat( + lines[None, :], size, axis=0 + ) + # Long-wavelength modulation so the brushing is not perfectly uniform. + return grain * 0.7 + value_noise(size, 8, rng) * 0.3 + + +def mat_steel(size: int, seed: int) -> dict: + rng = np.random.default_rng(seed) + + grain = brushed(size, rng, along=1) + plate = fbm(size, rng, octaves=4, base_freq=3) + dents = 1.0 - worley(size, 14, rng) + scratch = np.clip(ridged(size, rng, octaves=3, base_freq=40) - 0.76, 0, 1) * 3.0 + grime = fbm(size, rng, octaves=5, base_freq=10) + + # Plate seams: a welded steel structure is made of panels, and the seam + # lines are what give a large flat surface any sense of scale. + u = np.linspace(0, 1, size, endpoint=False) + seam_x = np.abs(((u * 3) % 1.0) - 0.5) + seam_y = np.abs(((u * 2) % 1.0) - 0.5) + seams = np.minimum(seam_x[None, :], seam_y[:, None]) + seams = np.clip(1.0 - seams * 26.0, 0, 1) + + height = normalise(grain * 0.18 + plate * 0.5 + dents * 0.32) - seams * 0.5 + scratch * 0.12 + + # Much wider tonal range than the flat first attempt. + base = 0.17 + plate * 0.16 + grain * 0.10 - grime * 0.05 + base = base - seams * 0.05 + albedo = np.stack([base * 0.96, base, base * 1.08], axis=-1) + + roughness = np.clip( + 0.30 + grain * 0.26 + plate * 0.14 + grime * 0.12 - scratch * 0.12, 0.12, 0.92 + ) + metallic = np.clip(0.94 - scratch * 0.25 - grime * 0.1, 0, 1) + ao = cavity_ao(height, radius=4) + + return { + "albedo": np.clip(albedo, 0, 1), + "normal": height_to_normal(height, strength=1.0), + "orm": np.stack([ao, roughness, metallic], axis=-1), + } + + +def mat_rust(size: int, seed: int) -> dict: + rng = np.random.default_rng(seed) + + # Three scales of blotching. A single Worley layer leaves its cell + # boundaries visible as a polygonal web — the first attempt looked like + # cracked mud rather than corrosion. + coarse = 1.0 - worley(size, 7, rng) + mid = 1.0 - worley(size, 22, rng) + fine_cell = 1.0 - worley(size, 70, rng) + fine = fbm(size, rng, octaves=7, base_freq=6) + pitting = fbm(size, rng, octaves=5, base_freq=40) + run = streaks(size, rng, strength=1.0) + + # How rusted each texel is. Rust spreads from blotch centres and runs down. + rust_mask = ( + coarse * 0.55 + mid * 0.28 + fine_cell * 0.17 + ) * 1.15 + fine * 0.45 + run * 0.6 - 0.62 + rust_mask = np.clip(rust_mask * 1.7, 0, 1) + # Ragged the boundary: a smooth edge between paint and rust looks airbrushed. + rust_mask = np.clip(rust_mask * (0.75 + 0.5 * pitting) * 1.25, 0, 1) + + height = normalise(fine * 0.42 + pitting * 0.2 + rust_mask * 0.28 + run * 0.1) + + steel_base = np.stack([np.full((size, size), 0.23)] * 3, axis=-1) + steel_base[..., 2] *= 1.08 + # Rust runs from dark red-brown in the pits to warm orange on the crust. + rust_col = np.stack( + [0.30 + fine * 0.22, 0.135 + fine * 0.10, 0.065 + fine * 0.04], axis=-1 + ) + + m = rust_mask[..., None] + albedo = np.clip(steel_base * (1 - m) + rust_col * m, 0, 1) + + roughness = np.clip(0.35 * (1 - rust_mask) + 0.93 * rust_mask + fine * 0.05, 0.2, 1.0) + # Rust is an oxide: it is not metallic, which is the main visual cue. + metallic = np.clip(0.9 * (1 - rust_mask), 0, 1) + ao = cavity_ao(height, radius=5) + + return { + "albedo": albedo, + "normal": height_to_normal(height, strength=1.5), + "orm": np.stack([ao, roughness, metallic], axis=-1), + } + + +def _painted_metal(size: int, seed: int, colour: tuple[float, float, float]) -> dict: + """Painted corrugated steel with chipping, chalking and dirt runoff.""" + rng = np.random.default_rng(seed) + + fine = fbm(size, rng, octaves=6, base_freq=5) + run = streaks(size, rng, strength=0.9) + scratch = np.clip(ridged(size, rng, octaves=3, base_freq=40) - 0.78, 0, 1) * 3.0 + + # Paint failure is clustered and irregular, not evenly scattered. Thresholding + # a single Worley layer (the first attempt) gave every chip the same round + # shape at the same spacing, which read as a leopard print rather than wear. + # + # Instead: a low-frequency "wear zone" decides *where* paint is failing at + # all, and high-frequency detail decides the ragged shape of each chip. + wear_zone = np.clip(fbm(size, rng, octaves=4, base_freq=3) * 1.7 - 0.55, 0, 1) + chip_edge = np.clip(ridged(size, rng, octaves=4, base_freq=16) - 0.62, 0, 1) * 2.6 + speck = np.clip(fbm(size, rng, octaves=6, base_freq=26) - 0.57, 0, 1) * 3.2 + + bare = (speck * 0.75 + chip_edge * 0.45) * (0.25 + wear_zone * 1.5) + bare = np.clip(bare * 1.3 + scratch * 0.55, 0, 1) + + height = normalise(fine * 0.55 + bare * 0.25) + scratch * 0.1 + + paint = np.stack( + [ + colour[0] * (0.86 + fine * 0.28), + colour[1] * (0.86 + fine * 0.28), + colour[2] * (0.86 + fine * 0.28), + ], + axis=-1, + ) + # Chalking: sun-bleached paint loses saturation before it loses adhesion. + chalk = fbm(size, rng, octaves=3, base_freq=3)[..., None] + paint = paint * (1 - chalk * 0.22) + chalk * 0.10 + + rust_col = np.stack( + [0.26 + fine * 0.16, 0.12 + fine * 0.07, np.full_like(fine, 0.06)], axis=-1 + ) + b = bare[..., None] + albedo = paint * (1 - b) + rust_col * b + albedo = np.clip(albedo - run[..., None] * 0.10, 0, 1) + + roughness = np.clip(0.58 + fine * 0.14 + bare * 0.3 + run * 0.08, 0.25, 1.0) + metallic = np.clip(0.06 + bare * 0.35, 0, 1) + ao = cavity_ao(height, radius=5) + + return { + "albedo": albedo, + "normal": height_to_normal(height, strength=1.2), + "orm": np.stack([ao, roughness, metallic], axis=-1), + } + + +def mat_paint_red(size: int, seed: int) -> dict: + return _painted_metal(size, seed, (0.62, 0.17, 0.18)) + + +def mat_paint_cyan(size: int, seed: int) -> dict: + return _painted_metal(size, seed, (0.16, 0.44, 0.47)) + + +def mat_grating(size: int, seed: int) -> dict: + """Industrial bar grating: strong directional bars with cross-ties.""" + rng = np.random.default_rng(seed) + + x = np.linspace(0, 1, size, endpoint=False) + bars = ((x * 32) % 1.0 < 0.62).astype(np.float64)[None, :].repeat(size, axis=0) + ties = ((np.linspace(0, 1, size, endpoint=False) * 8) % 1.0 < 0.16).astype(np.float64)[ + :, None + ].repeat(size, axis=1) + solid = np.clip(bars + ties, 0, 1) + + grime = fbm(size, rng, octaves=5, base_freq=6) + height = solid * 0.85 + grime * 0.15 + + base = (0.18 + grime * 0.06) * solid + 0.02 * (1 - solid) + albedo = np.stack([base * 0.98, base, base * 1.05], axis=-1) + + roughness = np.clip(0.45 + grime * 0.2 + (1 - solid) * 0.3, 0.2, 1.0) + metallic = np.clip(0.85 * solid, 0, 1) + # The gaps between bars are holes: force them dark and fully occluded. + ao = np.clip(cavity_ao(height, radius=3) * (0.25 + 0.75 * solid), 0, 1) + + return { + "albedo": np.clip(albedo, 0, 1), + "normal": height_to_normal(height, strength=2.2), + "orm": np.stack([ao, roughness, metallic], axis=-1), + } + + +def mat_rubber(size: int, seed: int) -> dict: + """ + Dark polymer: weapon furniture, webbing, boots. + + Albedo sits around 0.12, not the 0.045 of true rubber. Physically 0.045 is + correct, but this is a night scene lit mostly by distant sodium lamps, and + at that exposure a 4.5% surface renders as a pure black silhouette — the + weapon viewmodel and the whole character read as cut-out shapes. 0.12 is + still unmistakably "black gear" while keeping form readable. + """ + rng = np.random.default_rng(seed) + fine = fbm(size, rng, octaves=6, base_freq=16) + coarse = fbm(size, rng, octaves=4, base_freq=4) + height = fine * 0.7 + coarse * 0.3 + base = 0.105 + fine * 0.05 + coarse * 0.025 + albedo = np.stack([base, base, base * 1.06], axis=-1) + return { + "albedo": np.clip(albedo, 0, 1), + "normal": height_to_normal(height, strength=0.8), + "orm": np.stack( + [ + cavity_ao(height, radius=3), + # Not uniformly matte: moulded polymer has a faint sheen, and + # the specular roll-off is most of what separates it from a + # flat black shape in low light. + np.clip(0.66 + fine * 0.2 + coarse * 0.08, 0, 1), + np.zeros((size, size)), + ], + axis=-1, + ), + } + + +def env_sky(size: int, seed: int) -> dict: + """ + Equirectangular environment map for image-based lighting. + + This is not decoration. Every metal in the yard (steel at 0.94 metallic, + grating, rust) reflects its environment and *nothing else* — with no + `scene.environmentTexture` a physically-based metal has nothing to reflect + and renders pure black, which is exactly how the first integration looked. + + The content mirrors the in-game sky: near-black zenith, a warm false-dawn + lobe on the northern horizon, and a dim ground bounce below. Matching them + matters — if the reflections disagree with the visible sky, metal reads as + if it belongs to a different scene. + + Width is 2:1 as equirectangular projection requires; v = 0 is the zenith. + """ + rng = np.random.default_rng(seed) + width, height = size, size // 2 + + v = np.linspace(0.0, 1.0, height)[:, None] + u = np.linspace(0.0, 1.0, width)[None, :] + + # Elevation ramp: 0 at zenith, 0.5 at horizon, 1 at nadir. + horizon = np.clip(1.0 - np.abs(v - 0.5) * 2.0, 0, 1) + + sky = np.zeros((height, width, 3)) + # Cold night sky above the horizon. + above = (v < 0.5).astype(float) + sky[..., 0] = (0.012 + 0.05 * horizon**3) * above + sky[..., 1] = (0.018 + 0.06 * horizon**3) * above + sky[..., 2] = (0.035 + 0.08 * horizon**3) * above + + # The false dawn: a warm lobe centred a quarter of the way round, falling + # off in both azimuth and elevation. + azimuth = np.cos((u - 0.25) * 2.0 * np.pi) + lobe = np.clip(azimuth, 0, 1) ** 2.0 + band = np.clip(1.0 - np.abs(v - 0.5) * 5.0, 0, 1) ** 2.0 + dawn = lobe * band * above + sky[..., 0] += dawn * 0.95 + sky[..., 1] += dawn * 0.52 + sky[..., 2] += dawn * 0.20 + + # Ground bounce below the horizon: dim, warm, and flat. + below = (v >= 0.5).astype(float) + fade = np.clip(1.0 - (v - 0.5) * 1.6, 0, 1) + sky[..., 0] += below * (0.035 + 0.05 * fade) + sky[..., 1] += below * (0.030 + 0.04 * fade) + sky[..., 2] += below * (0.028 + 0.035 * fade) + + # A little noise stops large flat regions banding once mip-mapped. + sky += (rng.random((height, width, 1)) - 0.5) * 0.004 + + return {"sky": np.clip(sky, 0, 1)} + + +MATERIALS = { + "concrete": (mat_concrete, 1101), + "steel": (mat_steel, 1202), + "rust": (mat_rust, 1303), + "paint_red": (mat_paint_red, 1404), + "paint_cyan": (mat_paint_cyan, 1505), + "grating": (mat_grating, 1606), + "rubber": (mat_rubber, 1707), +} + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--out", required=True) + parser.add_argument("--size", type=int, default=RES) + parser.add_argument("--only", default=None, help="comma-separated material names") + args = parser.parse_args() + + wanted = set(args.only.split(",")) if args.only else set(MATERIALS) | {"env"} + + for name, (fn, seed) in MATERIALS.items(): + if name not in wanted: + continue + maps = fn(args.size, seed) + for kind, data in maps.items(): + path = os.path.join(args.out, f"{name}_{kind}.png") + write_png(path, data) + print(f"TEXTURE {name}_{kind}.png {os.path.getsize(path)}") + + if "env" in wanted: + # Half resolution: an environment map is only ever sampled blurred, so + # detail here is wasted bytes. + for kind, data in env_sky(max(512, args.size // 2), 1808).items(): + path = os.path.join(args.out, f"env_{kind}.png") + write_png(path, data) + print(f"TEXTURE env_{kind}.png {os.path.getsize(path)}") + + +if __name__ == "__main__": + main()