The 3D board textures can be extracted as WebP: each player downloads about 4.7 MB less on a cold start (#186)

Only the extraction (tools/local-extract) and the manifest pointer change; no official textures are committed and nothing in play changes. Contributed by @Cloudnyco.
This commit is contained in:
Cloudnyco authored and GitHub committed 2026-10-06 05:05:42 +08:00
1 parent 54cdad2602
commit a9dfd17bee
6 files changed
+209 -12

No files matched your search

+81 -4
View File
@@ -1,12 +1,14 @@
// test/render/board3d-extract.test.js — the local-client exports behind the official 3D board (DESIGN §13/§15):
// tools/local-extract/extract.py's board jobs (gate / objective effects, blower texture, water & noise maps), the
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the prefab mesh keys, and —
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the WebP copies of the textures
// the renderer downloads (extract.py WEBP), the prefab mesh keys, and —
// when a client was extracted here — the files and manifest entries the renderer loads (render/board3d/load.js).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { decodePng } from '../../tools/crop-board-atlas.mjs';
@@ -18,6 +20,7 @@ const TOOL = path.join(ROOT, 'tools/local-extract');
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
const HAS_PIL = !!PY && spawnSync(PY, ['-c', 'import PIL'], { env: ENV }).status === 0;
const HAS_WEBP = HAS_PIL && spawnSync(PY, ['-c', "import sys; from PIL import features; sys.exit(0 if features.check('webp') else 1)"], { env: ENV }).status === 0;
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
function py(code) {
@@ -62,6 +65,68 @@ print(json.dumps({'n': [list(rn.getpixel((i, 0))) for i in range(3)], 'm': [list
assert.deepEqual(out.m, [[255, 255, 0], [255, 55, 0]]);
});
test('WebP copies: exactly the board textures the renderer downloads; normal and data maps stay lossless', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const { webp } = JSON.parse(r.stdout);
assert.deepEqual(webp.map((w) => `${w.sub}/${w.name}`).sort(), Object.values(PACK_IMAGES).map(([g, n]) => `${g}/${n}`).sort());
const mode = Object.fromEntries(webp.map((w) => [w.name, w.mode]));
for (const n of ['TX_autochessi_N_rgb', '[ucp]TX_water_normal', 'TX_autochessi_M_rough', 'T_noise_clouds_01']) assert.equal(mode[n], 'lossless', n);
for (const n of ['TX_autochessi_D', 'TX_autochessi_BG', 'TX_autochessi_common_D']) assert.equal(mode[n], 'lossy', n);
});
test('run_webp / --webp: a copy beside the PNG that the manifest lists; normals exact, alpha and hidden RGB kept', { skip: !HAS_WEBP && 'no Pillow with WebP' }, () => {
const dir = mkdtempSync(path.join(tmpdir(), 'sp-webp-'));
try {
const out = py(`
from pathlib import Path
from PIL import Image
root = Path(${JSON.stringify(dir)})
sub = root / 'map' / 'autochess'
sub.mkdir(parents=True)
d = Image.new('RGBA', (64, 64), (200, 40, 40, 0)) # transparent texels whose colour the opaque board material shows
for x in range(32):
for y in range(64): d.putpixel((x, y), (x * 8, y * 4, 90, 255))
d.save(sub / 'TX_autochessi_D.png')
n = Image.new('RGB', (64, 64))
for x in range(64):
for y in range(64): n.putpixel((x, y), ((x * 37) % 256, (y * 53) % 256, 200 + (x + y) % 56))
n.save(sub / 'TX_autochessi_N_rgb.png')
entry = lambda name, kind: {'path': f'/assets/local/map/autochess/{name}.png', 'w': 64, 'h': 64, 'kind': kind}
groups = {'map/autochess': {'TX_autochessi_D': entry('TX_autochessi_D', 'Texture2D'),
'TX_autochessi_N_rgb': entry('TX_autochessi_N_rgb', 'Derived'),
'TX_autochessi_E': entry('TX_autochessi_E', 'Texture2D')}}
mf = root / 'local-assets.json'
mf.write_text(json.dumps({'version': 1, 'source': 'local-client', 'count': 3, 'groups': groups}))
logs = []
code = e.webp_only(root, mf, logs.append)
doc = json.loads(mf.read_text())
g = doc['groups']['map/autochess']
px = lambda p: list(Image.open(p).convert('RGBA').getdata())
a, b = px(sub / 'TX_autochessi_D.png'), px(sub / 'TX_autochessi_D.webp')
hidden = [q for p, q in zip(a, b) if p[3] == 0]
print(json.dumps({'code': code, 'paths': {k: v['path'] for k, v in g.items()}, 'modes': {k: v.get('webp') for k, v in g.items()},
'count': doc['count'], 'pngKept': (sub / 'TX_autochessi_D.png').exists(),
'alphaSame': all(p[3] == q[3] for p, q in zip(a, b)),
'normalSame': px(sub / 'TX_autochessi_N_rgb.png') == px(sub / 'TX_autochessi_N_rgb.webp'),
'hidden': [sum(q[i] for q in hidden) / len(hidden) for i in range(3)],
'again': e.run_webp(root, 'map/autochess', doc['groups'], logs.append)}))`);
assert.equal(out.code, 0);
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
// TX_autochessi_E is listed without a PNG on disk: its entry stays as it was
assert.deepEqual(out.paths, { TX_autochessi_D: at('TX_autochessi_D', 'webp'), TX_autochessi_N_rgb: at('TX_autochessi_N_rgb', 'webp'), TX_autochessi_E: at('TX_autochessi_E', 'png') });
assert.deepEqual(out.modes, { TX_autochessi_D: 'lossy', TX_autochessi_N_rgb: 'lossless', TX_autochessi_E: null });
assert.equal(out.count, 3);
assert.ok(out.pngKept, 'the PNG stays beside the copy (crop tool, setup)');
assert.ok(out.alphaSame, 'alpha lossless');
assert.ok(out.normalSame, 'normal map lossless');
out.hidden.forEach((v, i) => assert.ok(Math.abs(v - [200, 40, 40][i]) < 12, `RGB under alpha 0 kept: ${out.hidden}`));
assert.equal(out.again, 2, 'a second run rewrites both copies');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('prefab_node names the exported OBJ of a mesh whose name is shared (map/fx has two Start_back meshes)', () => {
const out = py(`
from types import SimpleNamespace as NS
@@ -92,6 +157,7 @@ describe('vendored three.js', () => {
describe('extracted board files (when a local client was extracted)', { skip: !manifest?.groups?.['map/fx'] && 'map/fx not extracted' }, () => {
const onDisk = (p) => path.join(ROOT, 'public', decodeURIComponent(p));
const pngOf = (p) => p.replace(/\.webp$/, '.png'); // a WebP copy (extract.py WEBP) keeps its PNG beside it
const hasFiles = existsSync(path.join(ROOT, 'public/assets/local/map/fx'));
test('every pack slot the renderer loads is in the manifest (and on disk)', () => {
@@ -113,13 +179,24 @@ describe('extracted board files (when a local client was extracted)', { skip: !m
test('derived maps on disk: RGB normals point out of the surface; roughness mirrors the smoothness', { skip: !hasFiles && 'files not extracted' }, () => {
const g = manifest.groups['map/autochess'];
const n = decodePng(readFileSync(onDisk(g.TX_autochessi_N_rgb.path)));
const n = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_N_rgb.path))));
let zSum = 0, cnt = 0;
for (let i = 0; i < n.rgba.length; i += 4 * 97) { zSum += n.rgba[i + 2]; cnt++; }
assert.ok(zSum / cnt > 230, `mean z ${zSum / cnt}`);
const m = decodePng(readFileSync(onDisk(g.TX_autochessi_M.path)));
const r = decodePng(readFileSync(onDisk(g.TX_autochessi_M_rough.path)));
const r = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_M_rough.path))));
assert.deepEqual([r.w, r.h], [m.w, m.h]);
for (let i = 0; i < m.rgba.length; i += 4 * 131) assert.equal(r.rgba[i + 1], 255 - m.rgba[i + 3]);
});
test('the WebP copies the manifest lists are on disk with their PNGs beside them', { skip: !hasFiles && 'files not extracted' }, () => {
for (const [g, entries] of Object.entries(manifest.groups)) {
for (const [n, e] of Object.entries(entries)) {
if (!/\.webp$/.test(e?.path || '')) continue;
assert.ok(existsSync(onDisk(e.path)), `${g}/${n}`);
assert.ok(existsSync(onDisk(pngOf(e.path))), `${g}/${n}: PNG`);
assert.ok(e.webp === 'lossy' || e.webp === 'lossless', `${g}/${n}: mode ${e.webp}`);
}
}
});
});
+23
View File
@@ -18,6 +18,7 @@ import { FX_KINDS, fxSpec, tilesAround } from '../../public/js/render/fx.js';
import { statusIconKey } from '../../public/js/render/style.js';
import { STATUS_KEYS } from '../../public/js/render/textures.js';
import { createAssets, localAssetUrl } from '../../public/js/assets.js';
import { loadBoardArt, resetBoardArt, sourceUrl } from '../../public/js/render/boardArt.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
@@ -311,4 +312,26 @@ describe('local-art manifest helpers', () => {
const broken = createAssets({ manifest: {}, fetch: async () => { throw new Error('offline'); } });
assert.equal(await broken.local(), null);
});
test('board art loads the crop-table textures at the URLs the manifest lists (one download with the 3D board)', async () => {
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
const m = { groups: { 'map/autochess': { TX_autochessi_D: { path: at('TX_autochessi_D', 'webp') }, TX_autochessi_BG: { path: at('TX_autochessi_BG', 'webp') } } } };
const asked = [];
const store = { local: async () => m, localUrl: (g, n) => localAssetUrl(m, g, n), image: async (u) => { asked.push(u); return { width: 4 }; } };
assert.equal(sourceUrl(store, at('TX_autochessi_D', 'png')), at('TX_autochessi_D', 'webp'));
assert.equal(sourceUrl(store, at('TX_autochessi_common_D', 'png')), at('TX_autochessi_common_D', 'png'), 'not in the manifest: the table path');
assert.equal(sourceUrl({}, '/x/y.png'), '/x/y.png');
const tiles = { version: 2, materials: {}, source: { D: { path: at('TX_autochessi_D', 'png') }, common: { path: at('TX_autochessi_common_D', 'png') }, BG: { path: at('TX_autochessi_BG', 'png') } } };
const realFetch = globalThis.fetch;
globalThis.fetch = async (u) => (u === '/assets/local/map/autochess/tiles.json' ? { ok: true, json: async () => tiles } : { ok: false, status: 404 });
try {
resetBoardArt();
const art = await loadBoardArt(store);
assert.ok(art?.images.D && art.images.common && art.images.BG);
assert.deepEqual(asked.sort(), [at('TX_autochessi_BG', 'webp'), at('TX_autochessi_D', 'webp'), at('TX_autochessi_common_D', 'png')]);
} finally {
globalThis.fetch = realFetch;
resetBoardArt();
}
});
});