The surface library can be photographed
`/simulate/assets` on lumbridgecorp.com argues that the art in this repository is code — eight textures drawn on a 2D canvas from seeded value noise, thirty-five surface roles pairing one of those with a colour and a roughness, and no binary art anywhere, enforced by a CI job. It made that argument in about four thousand pixels of prose with nothing on it to look at, which is a strange way to talk about the appearance of things. `textures.mjs` writes the library out: one tile per texture kind drawn by `TextureBin`, multiplied by the colour of the first role that carries it, and a neutral tile beside it so a reader can see for themselves that the map has no hue of its own. The role table goes with them as data, so the site sets it in its own type rather than baking labels into an image. It is the one capture here that runs against Vite dev rather than `dist/`, and the reason is in the header of both files. `shots.mjs` and `films.mjs` photograph the *application*, and the application a visitor gets is the built bundle. This photographs a *module*. `TextureBin` is not reachable from the bundle — it exposes no names — and making it reachable would mean a third Vite entry, which would ship a texture-sheet page to tera.lumbridgecorp.com so that a script could screenshot it. Vite dev transforms `/src/assets/*.ts` on request, so the page imports the same files a reader opens on the repository. Nothing in it re-implements the tables in `materials.ts`: it asks `MaterialRegistry` for every role in `DEFAULT_INTERIOR_PALETTE` and reports what comes back, so a role that changes its texture or its roughness changes the sheet on the website with it. No GPU, no WebGL, no renderer string to check — Canvas2D and the browser's own WebP encoder, about two seconds for all sixteen tiles at 97 kB total. `check-no-binaries` still passes: these live under `scripts/`, and the tiles are written into the lumbridge-v4 checkout, not this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!--
|
||||||
|
The surface library, drawn by the engine that ships it.
|
||||||
|
|
||||||
|
Served by Vite's dev server rather than out of `dist/`, which is the one place
|
||||||
|
this differs from every other script in here. The others photograph the built
|
||||||
|
bundle because what they are photographing is the *app* — a camera pose, a
|
||||||
|
city, an hour of light — and the built bundle is what a visitor gets.
|
||||||
|
|
||||||
|
This photographs a module. `TextureBin` and `MaterialRegistry` are not reachable
|
||||||
|
from `dist/index.js`: the bundle exposes no names, and adding a third Vite entry
|
||||||
|
to reach them would ship a texture-sheet page to tera.lumbridgecorp.com for the
|
||||||
|
sake of a screenshot. Vite dev transforms `/src/assets/*.ts` on request, so the
|
||||||
|
page below imports the very files a reader will open on the repository — which
|
||||||
|
is a shorter chain of custody than the bundle, not a longer one.
|
||||||
|
|
||||||
|
Nothing here is loaded by the app and nothing here is built.
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>tera — surface library</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background: #08090b; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="module">
|
||||||
|
import { MaterialRegistry } from "/src/assets/materials.ts";
|
||||||
|
import { DEFAULT_INTERIOR_PALETTE } from "/src/assets/palette.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registry is asked for every role, in the order the palette declares
|
||||||
|
* them, and answers with the material the office actually renders — the
|
||||||
|
* same object, from the same cache. Nothing here re-implements the table
|
||||||
|
* in `materials.ts`; if a role changes its texture or its roughness, this
|
||||||
|
* page changes with it and so does the sheet on the website.
|
||||||
|
*/
|
||||||
|
const registry = new MaterialRegistry({ quality: "high" });
|
||||||
|
const roles = Object.keys(DEFAULT_INTERIOR_PALETTE);
|
||||||
|
|
||||||
|
/** role -> what the engine gives it. */
|
||||||
|
const surfaces = roles.map((role) => {
|
||||||
|
const material = registry.get(role);
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
// `getHexString` converts back out of the linear working space, so
|
||||||
|
// this is the sRGB value the palette declared rather than three's
|
||||||
|
// internal one.
|
||||||
|
color: `#${material.color.getHexString()}`,
|
||||||
|
texture: material.map?.name ?? null,
|
||||||
|
roughness: material.roughness ?? null,
|
||||||
|
metalness: material.metalness ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tile: the texture as the map, multiplied by the colour of a role that
|
||||||
|
* carries it.
|
||||||
|
*
|
||||||
|
* Multiply is not an approximation of the shader — it is what the shader
|
||||||
|
* does. `map * color` is the whole of the diffuse term here, and drawing
|
||||||
|
* the map on its own would be truthful about `textures.ts` and misleading
|
||||||
|
* about what a floor looks like, because every texture in the library is
|
||||||
|
* deliberately near-white and modulates downward.
|
||||||
|
*
|
||||||
|
* The neutral map is emitted too, so the page can put the two side by
|
||||||
|
* side and let the reader see the split rather than read about it.
|
||||||
|
*/
|
||||||
|
function tile(kind, hex) {
|
||||||
|
const map = registry.textures.get(kind);
|
||||||
|
if (!map) throw new Error(`no texture drawn for ${kind}`);
|
||||||
|
const source = map.image;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = source.width;
|
||||||
|
canvas.height = source.height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.drawImage(source, 0, 0);
|
||||||
|
if (hex) {
|
||||||
|
ctx.globalCompositeOperation = "multiply";
|
||||||
|
ctx.fillStyle = hex;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
return canvas.toDataURL("image/webp", 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__library = () => {
|
||||||
|
/** kind -> the roles that carry it, in palette order. */
|
||||||
|
const byKind = new Map();
|
||||||
|
for (const surface of surfaces) {
|
||||||
|
if (!surface.texture) continue;
|
||||||
|
const list = byKind.get(surface.texture) ?? [];
|
||||||
|
list.push(surface.role);
|
||||||
|
byKind.set(surface.texture, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textures = [...byKind.entries()].map(([kind, carriedBy]) => {
|
||||||
|
// The first role in palette order, so the representative colour is
|
||||||
|
// decided by the data rather than by whoever ran the script.
|
||||||
|
const lead = surfaces.find((s) => s.role === carriedBy[0]);
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
carriedBy,
|
||||||
|
leadRole: lead.role,
|
||||||
|
leadColor: lead.color,
|
||||||
|
tinted: tile(kind, lead.color),
|
||||||
|
neutral: tile(kind, null),
|
||||||
|
size: registry.textures.get(kind).image.width,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { textures, surfaces };
|
||||||
|
};
|
||||||
|
|
||||||
|
// The tiles are drawn synchronously on import, so the harness has nothing
|
||||||
|
// to wait on but this flag.
|
||||||
|
document.body.dataset.ready = "1";
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* Photograph the surface library.
|
||||||
|
*
|
||||||
|
* node scripts/brand-assets/textures.mjs
|
||||||
|
*
|
||||||
|
* `/simulate/assets` on lumbridgecorp.com is a page whose whole argument is that
|
||||||
|
* the art in this repository is code — eight texture kinds drawn on a 2D canvas
|
||||||
|
* from seeded value noise, thirty-five surface roles that pair one of those with
|
||||||
|
* a colour and a roughness, and no binary art anywhere, enforced by a CI job.
|
||||||
|
* It made that argument in about four thousand pixels of prose with nothing on
|
||||||
|
* it to look at, which is a strange way to talk about the appearance of things.
|
||||||
|
*
|
||||||
|
* So this writes the library out: one tile per texture kind, drawn by
|
||||||
|
* `TextureBin`, multiplied by the colour of the first role that carries it, and
|
||||||
|
* one neutral tile beside it so a reader can see for themselves that the map has
|
||||||
|
* no hue of its own. The role table goes with them as data, so the site can set
|
||||||
|
* it in its own type rather than baking labels into an image.
|
||||||
|
*
|
||||||
|
* ### Why this one runs against Vite dev and the others run against `dist/`
|
||||||
|
*
|
||||||
|
* `shots.mjs` and `films.mjs` photograph the *application*, and the application
|
||||||
|
* a visitor gets is the built bundle, so that is what they point a camera at.
|
||||||
|
* This photographs a *module*. `TextureBin` is not reachable from the built
|
||||||
|
* bundle — it exposes no names — and making it reachable would mean a third Vite
|
||||||
|
* entry, which would ship a texture-sheet page to tera.lumbridgecorp.com so that
|
||||||
|
* a script could take a screenshot of it. Vite dev serves and transforms the
|
||||||
|
* source on request, so the page imports the same files a reader opens on the
|
||||||
|
* repository. That is a shorter chain of custody than the bundle, not a longer
|
||||||
|
* one, and the manifest records the commit either way.
|
||||||
|
*
|
||||||
|
* ### It needs no GPU
|
||||||
|
*
|
||||||
|
* Nothing here draws with WebGL. The textures are Canvas2D and the encoder is
|
||||||
|
* the browser's own WebP, so this runs in a couple of seconds on the software
|
||||||
|
* path and there is nothing to check a renderer string for. Chrome is still the
|
||||||
|
* right tool: `toDataURL("image/webp")` is the encoder, exactly as in
|
||||||
|
* `shots.mjs`, because there is no `sharp` on this box.
|
||||||
|
*
|
||||||
|
* ### Flags
|
||||||
|
*
|
||||||
|
* --site <dir> where lumbridge-v4 is (default ../../lumbridge-v4)
|
||||||
|
* --manifest-only rewrite the generated TS without drawing anything
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { createServer } from "vite";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = join(HERE, "..", "..");
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
function flag(name, fallback = null) {
|
||||||
|
const i = args.indexOf(name);
|
||||||
|
return i === -1 ? fallback : args[i + 1];
|
||||||
|
}
|
||||||
|
const SITE = flag("--site", join(ROOT, "..", "lumbridge-v4"));
|
||||||
|
const MANIFEST_ONLY = args.includes("--manifest-only");
|
||||||
|
|
||||||
|
const PUBLIC_DIR = join(SITE, "apps", "web", "public", "textures");
|
||||||
|
const DATA_DIR = join(SITE, "apps", "web", "src", "data");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sentence per texture kind, written here rather than on the website.
|
||||||
|
*
|
||||||
|
* Same rule the shot captions follow: the description lives next to the thing
|
||||||
|
* that produces it, so a texture that is rewritten cannot leave a paragraph
|
||||||
|
* behind on a marketing page describing the grain it used to have. The website
|
||||||
|
* renders these; it does not author them.
|
||||||
|
*
|
||||||
|
* Every claim below is checkable against `DRAW` in `src/assets/textures.ts`.
|
||||||
|
*/
|
||||||
|
const NOTES = {
|
||||||
|
carpetLoop:
|
||||||
|
"Two octaves of value noise for the pile, then a loop pattern punched over it on a half-offset grid — the same trick a real loop carpet uses to hide its seams.",
|
||||||
|
woodPlank:
|
||||||
|
"Planks of a random width, each with its own grain running the length of it, and a hairline gap where two meet.",
|
||||||
|
polishedConcrete:
|
||||||
|
"Broad, slow noise for the pour, a fine speckle for the aggregate, and a few pale trowel sweeps that stop it reading as a gradient.",
|
||||||
|
ceilingTile:
|
||||||
|
"A mineral-fibre face: dense fine noise, pinholes punched through it, and a chamfer drawn around the edge of the tile.",
|
||||||
|
plasterPaint:
|
||||||
|
"Almost nothing, on purpose. Roller texture at the threshold of visibility is what keeps a painted wall from reading as a flat fill under raking light.",
|
||||||
|
fabricWeave:
|
||||||
|
"A warp and a weft drawn as alternating lines, which is enough structure to catch a highlight without becoming a pattern anybody notices.",
|
||||||
|
tileGrid:
|
||||||
|
"A hard grid of grout lines over lightly varied tiles, so no two squares in a floor are quite the same value.",
|
||||||
|
whiteboard:
|
||||||
|
"A near-white gloss with a faint vertical wipe and a few ghosted horizontal strokes — a board that has been used and cleaned, not a board out of a box.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function sha() {
|
||||||
|
try {
|
||||||
|
return execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: ROOT })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirty() {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
execFileSync("git", ["status", "--porcelain"], { cwd: ROOT }).toString().trim() !== ""
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function draw() {
|
||||||
|
// `server.middlewareMode` is off: the page is fetched over HTTP like any
|
||||||
|
// other, so Vite's HTML transform runs and the inline module script is
|
||||||
|
// rewritten with resolved import URLs.
|
||||||
|
const server = await createServer({
|
||||||
|
root: ROOT,
|
||||||
|
logLevel: "warn",
|
||||||
|
server: { port: 5313, strictPort: true },
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ channel: "chrome" });
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(String(error)));
|
||||||
|
await page.goto("http://localhost:5313/scripts/brand-assets/textures.html", {
|
||||||
|
waitUntil: "networkidle",
|
||||||
|
});
|
||||||
|
// The tiles are drawn during module evaluation, so this is the whole wait.
|
||||||
|
// `waitForFunction`, not `waitForSelector`: the page paints nothing, so its
|
||||||
|
// body has no height, and Playwright's visibility rule counts a zero-height
|
||||||
|
// element as hidden however present it is.
|
||||||
|
await page.waitForFunction(() => document.body.dataset.ready === "1", null, {
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
if (errors.length) throw new Error(`page errors: ${errors.join("; ")}`);
|
||||||
|
|
||||||
|
const library = await page.evaluate(() => window.__library());
|
||||||
|
if (!library.textures.length) throw new Error("no textures came back");
|
||||||
|
return library;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifest(library) {
|
||||||
|
const rows = library.textures
|
||||||
|
.map(
|
||||||
|
(t) => ` {
|
||||||
|
kind: ${JSON.stringify(t.kind)},
|
||||||
|
src: ${JSON.stringify(`/textures/${t.kind}.webp`)},
|
||||||
|
neutralSrc: ${JSON.stringify(`/textures/${t.kind}-neutral.webp`)},
|
||||||
|
size: ${t.size},
|
||||||
|
leadRole: ${JSON.stringify(t.leadRole)},
|
||||||
|
leadColor: ${JSON.stringify(t.leadColor)},
|
||||||
|
carriedBy: ${JSON.stringify(t.carriedBy)},
|
||||||
|
note: ${JSON.stringify(NOTES[t.kind] ?? "")},
|
||||||
|
},`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const surfaces = library.surfaces
|
||||||
|
.map(
|
||||||
|
(s) =>
|
||||||
|
` { role: ${JSON.stringify(s.role)}, color: ${JSON.stringify(s.color)}, texture: ${JSON.stringify(s.texture)}, roughness: ${s.roughness}, metalness: ${s.metalness} },`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return `/**
|
||||||
|
* Generated. Do not edit — \`scripts/brand-assets/textures.mjs\` in the tera repo
|
||||||
|
* rewrites this file wholesale, and the descriptions below live next to the code
|
||||||
|
* that draws each texture so the two cannot drift apart.
|
||||||
|
*
|
||||||
|
* Every tile named here was drawn by tera's own \`TextureBin\` at the commit
|
||||||
|
* recorded below, and every row in \`SURFACES\` is what \`MaterialRegistry\` hands
|
||||||
|
* the office for that role. Nothing is an illustration and nothing is retouched.
|
||||||
|
*
|
||||||
|
* To change a description: edit \`NOTES\` in that script and run it with
|
||||||
|
* \`--manifest-only\`, which redraws nothing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A union rather than \`string\`, so a page naming a texture that is gone fails typecheck. */
|
||||||
|
export type TextureKind =
|
||||||
|
${library.textures.map((t) => ` | ${JSON.stringify(t.kind)}`).join("\n")};
|
||||||
|
|
||||||
|
export interface TextureTile {
|
||||||
|
kind: TextureKind;
|
||||||
|
/** The map multiplied by \`leadColor\` — what the office actually renders. */
|
||||||
|
src: string;
|
||||||
|
/** The map alone. Near-white by construction: the hue arrives from the palette. */
|
||||||
|
neutralSrc: string;
|
||||||
|
/** Pixels per side, which is also metres-per-repeat divided into resolution. */
|
||||||
|
size: number;
|
||||||
|
/** The first role in palette order that carries this texture. */
|
||||||
|
leadRole: string;
|
||||||
|
leadColor: string;
|
||||||
|
/** Every role that carries it. */
|
||||||
|
carriedBy: string[];
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row per surface role, as \`MaterialRegistry\` builds it at \`high\` quality. */
|
||||||
|
export interface Surface {
|
||||||
|
role: string;
|
||||||
|
color: string;
|
||||||
|
texture: string | null;
|
||||||
|
roughness: number | null;
|
||||||
|
metalness: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tera commit these were drawn from. */
|
||||||
|
export const TEXTURES_COMMIT = ${JSON.stringify(sha())};
|
||||||
|
/** True if that commit is not the whole story — the tree had uncommitted work. */
|
||||||
|
export const TEXTURES_DIRTY = ${dirty()};
|
||||||
|
|
||||||
|
export const TEXTURES: TextureTile[] = [
|
||||||
|
${rows}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SURFACES: Surface[] = [
|
||||||
|
${surfaces}
|
||||||
|
];
|
||||||
|
|
||||||
|
/** How many metres one repeat covers. From \`TEXTURE_TILE_METRES\` in tera. */
|
||||||
|
export const TEXTURE_TILE_METRES = 2;
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = (value) => Buffer.from(value.split(",")[1], "base64");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (MANIFEST_ONLY) {
|
||||||
|
// Redrawing is the only way to know the role table, so a manifest-only run
|
||||||
|
// still needs the page — it just does not write any images.
|
||||||
|
const library = await draw();
|
||||||
|
await writeFile(join(DATA_DIR, "textures.generated.ts"), manifest(library));
|
||||||
|
console.log(`manifest only → ${join(DATA_DIR, "textures.generated.ts")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = await draw();
|
||||||
|
await mkdir(PUBLIC_DIR, { recursive: true });
|
||||||
|
|
||||||
|
let bytes = 0;
|
||||||
|
for (const tile of library.textures) {
|
||||||
|
const tinted = dataUrl(tile.tinted);
|
||||||
|
const neutral = dataUrl(tile.neutral);
|
||||||
|
await writeFile(join(PUBLIC_DIR, `${tile.kind}.webp`), tinted);
|
||||||
|
await writeFile(join(PUBLIC_DIR, `${tile.kind}-neutral.webp`), neutral);
|
||||||
|
bytes += tinted.length + neutral.length;
|
||||||
|
console.log(
|
||||||
|
`${tile.kind.padEnd(18)} ${tile.size}px ${tile.leadColor} ${tile.carriedBy.length} role(s) ${Math.round((tinted.length + neutral.length) / 1024)} kB`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(join(DATA_DIR, "textures.generated.ts"), manifest(library));
|
||||||
|
console.log(
|
||||||
|
`\n${library.textures.length} textures, ${library.surfaces.length} surface roles, ${Math.round(bytes / 1024)} kB total → ${SITE}`,
|
||||||
|
);
|
||||||
|
if (dirty()) console.log("warning: tera's tree is dirty; the manifest names a dirty sha");
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
Reference in New Issue
Block a user