Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions apps/game/public/assets/PROVENANCE.md
Original file line number Diff line number Diff line change
@@ -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.
50 changes: 50 additions & 0 deletions apps/game/public/assets/manifest.json
Original file line number Diff line number Diff line change
@@ -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
}
}
Binary file added apps/game/public/assets/models/carbine.glb
Binary file not shown.
Binary file added apps/game/public/assets/models/character.glb
Binary file not shown.
Binary file added apps/game/public/assets/models/container.glb
Binary file not shown.
Binary file added apps/game/public/assets/models/deck.glb
Binary file not shown.
Binary file added apps/game/public/assets/models/hardpoint.glb
Binary file not shown.
Binary file added apps/game/public/assets/models/lamp_mast.glb
Binary file not shown.
Binary file added apps/game/public/assets/models/pipe_rack.glb
Binary file not shown.
Binary file added apps/game/public/assets/models/stair.glb
Binary file not shown.
Binary file added apps/game/public/assets/models/tank.glb
Binary file not shown.
Binary file added apps/game/public/assets/models/wall.glb
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/game/public/assets/textures/env_sky.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/game/public/assets/textures/grating_orm.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/game/public/assets/textures/rubber_orm.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/game/public/assets/textures/rust_albedo.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/game/public/assets/textures/rust_orm.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/game/public/assets/textures/steel_orm.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
145 changes: 145 additions & 0 deletions apps/game/src/assets.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>([...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");
});
});
Loading
Loading