diff --git a/public/js/render/board3d/materials.js b/public/js/render/board3d/materials.js index d986c485..b122bebf 100644 --- a/public/js/render/board3d/materials.js +++ b/public/js/render/board3d/materials.js @@ -1,8 +1,8 @@ // render/board3d/materials.js — three.js materials of the official board scene (DESIGN §15). THREE is injected // (the renderer loads /vendor/three.module.js; Node tests pass the npm package). // -// MT_autochess → MeshStandardMaterial: albedo TX_autochessi_D (sRGB), normal TX_autochessi_N (BC5 → RGB, -// tools/local-extract DERIVED), roughness 1 − smoothness of TX_autochessi_M (DERIVED), metal 0, +// MT_autochess → MeshStandardMaterial: albedo TX_autochessi_D (sRGB), normal TX_autochessi_N (BC5 / DXT5nm → +// RGB, tools/local-extract DERIVED), roughness 1 − smoothness of TX_autochessi_M (DERIVED), metal 0, // emission TX_autochessi_E (_EmissionColor white), vertex colours = baked AO / tints // MT_autochess_common → device plates of TX_autochessi_common_D, alpha test 0.408, emission _E × 0.559 // MT_autochessi_BG → unlit TX_autochessi_BG × 0.713 (Torappu/Unlit/Texture, _Color) diff --git a/test/render/board3d-extract.test.js b/test/render/board3d-extract.test.js index 864a7a08..5df1d688 100644 --- a/test/render/board3d-extract.test.js +++ b/test/render/board3d-extract.test.js @@ -1,6 +1,6 @@ // 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 WebP copies of the textures +// derived three.js maps (BC5 / DXT5nm 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). @@ -65,6 +65,23 @@ 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('derived normal map: the DXT5nm layout (R = 1, G = Y, A = X) gives the same normals as BC5 (R = X, G = Y)', { skip: !HAS_PIL && 'no Pillow' }, () => { + const out = py(` +from PIL import Image +xy = [(128, 128), (255, 128), (128, 20), (90, 200), (0, 128), (37, 37)] +bc5 = Image.new('RGB', (len(xy), 1)); nm = Image.new('RGBA', (len(xy), 1)); opaque = Image.new('RGBA', (len(xy), 1)) +for i, (x, y) in enumerate(xy): + bc5.putpixel((i, 0), (x, y, 0)); nm.putpixel((i, 0), (255, y, y, x)); opaque.putpixel((i, 0), (x, y, 0, 255)) +px = lambda im: [list(im.getpixel((i, 0))) for i in range(len(xy))] +print(json.dumps({'bc5': px(e.derive_normal_rg(bc5)), 'nm': px(e.derive_normal_rg(nm)), 'opaque': px(e.derive_normal_rg(opaque)), + 'mode': e.derive_normal_rg(nm).mode, 'xy': xy}))`); + assert.equal(out.mode, 'RGB'); + assert.deepEqual(out.nm, out.bc5, 'X read from A when R is 1'); + assert.deepEqual(out.opaque, out.bc5, 'a BC5 map decoded with an opaque alpha'); + assert.deepEqual(out.nm.map((p) => p.slice(0, 2)), out.xy, 'XY kept'); + assert.ok(out.nm[0][2] >= 254 && out.nm[3][2] > 128, 'z rebuilt, not the 128 of x = 1'); + }); + 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); @@ -182,7 +199,7 @@ describe('extracted board files (when a local client was extracted)', { skip: !m 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}`); + assert.ok(zSum / cnt > 230, `mean z ${zSum / cnt} (128 = made by an extract.py that read a DXT5nm map as BC5: re-extract with --only map/autochess)`); const m = decodePng(readFileSync(onDisk(g.TX_autochessi_M.path))); const r = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_M_rough.path)))); assert.deepEqual([r.w, r.h], [m.w, m.h]); diff --git a/tools/local-extract/extract.py b/tools/local-extract/extract.py index f3e81105..45c607dd 100644 --- a/tools/local-extract/extract.py +++ b/tools/local-extract/extract.py @@ -28,9 +28,10 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l [opt]end_box / [opt]start_fly meshes, their merged additive textures, materials and prefab), the blower's texture (arts/maps/common/res.ab → map/common: TX_wind_device of the s_wind_device mesh) and the shared water / noise textures (arts/maps/effect.ab → map/water) used for deep-sea / mire / smog tiles - - derived PBR maps for three.js (DERIVED): Unity stores the theme's normal map as two channels (BC5: RG = XY, B = 0) - and metallic/gloss with the smoothness in A; the board renderer needs an RGB normal map (Z rebuilt) and a - roughness map in G (1 − smoothness; metalness B = 0), written next to the source as _rgb.png / _rough.png + - derived PBR maps for three.js (DERIVED): Unity stores the theme's normal map as two channels (BC5: RG = XY, B = 0; + DXT5nm in an older bundle: AG = XY, R = 1) and metallic/gloss with the smoothness in A; the board renderer needs + an RGB normal map (Z rebuilt) and a roughness map in G (1 − smoothness; metalness B = 0), written next to the + source as _rgb.png / _rough.png - WebP copies of the board textures every player downloads with the 3D board (WEBP), written next to their PNGs; the manifest lists the copy @@ -148,8 +149,8 @@ TOKEN_PACKS = 'pkgrps/btl_pfb_tokens_*.ab' TOKEN_SPINE_SUB = 'spine/token' # Derived three.js maps: (output subdir, source texture, kind, output name). 'normal_rg' rebuilds Z of a two-channel -# (BC5) normal map into an RGB tangent-space map; 'rough_from_gloss' turns Unity's metallic/gloss map (smoothness in -# A) into a roughness (G) / metalness (B) map. Written after the job that exported the source texture. +# (BC5 or DXT5nm) normal map into an RGB tangent-space map; 'rough_from_gloss' turns Unity's metallic/gloss map +# (smoothness in A) into a roughness (G) / metalness (B) map. Written after the job that exported the source texture. DERIVED = [ ('map/autochess', 'TX_autochessi_N', 'normal_rg', 'TX_autochessi_N_rgb'), ('map/autochess', 'TX_autochessi_M', 'rough_from_gloss', 'TX_autochessi_M_rough'), @@ -400,13 +401,16 @@ def _normal_z_table(): def derive_normal_rg(img): - """RGB tangent-space normal map from a two-channel (BC5: R = X, G = Y, B unused) one: Z rebuilt per pixel.""" - from PIL import Image - rgb = img.convert('RGB') - r, g, _ = rgb.split() + """RGB tangent-space normal map from Unity's two-channel one: Z rebuilt per pixel. X and Y are read the way Unity's + shaders unpack them (UnityCG.cginc UnpackNormalmapRGorAG: x = R · A, y = G), which covers both layouts a client + build holds: BC5 (R = X, G = Y, B unused, no alpha → A = 1) and DXT5nm (R = 1, G = Y, A = X — the map_autochess + bundle before the client update of 2026-10).""" + from PIL import Image, ImageChops + r, g, _, a = img.convert('RGBA').split() + x = ImageChops.multiply(r, a) # exact when either factor is 255 table = _normal_z_table() - z = bytes(table[(x << 8) | y] for x, y in zip(r.tobytes(), g.tobytes())) - return Image.merge('RGB', (r, g, Image.frombytes('L', rgb.size, z))) + z = bytes(table[(xb << 8) | yb] for xb, yb in zip(x.tobytes(), g.tobytes())) + return Image.merge('RGB', (x, g, Image.frombytes('L', img.size, z))) def derive_rough_from_gloss(img):