diff --git a/.gitignore b/.gitignore index 64d27ba..2863305 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,13 @@ tools/.cache/ # Test + screenshot artefacts shooter_*.png shooter_selftest_*.png +tools/.testout/ + +# Windows build + run artefacts +main.exe +main.lib +.perry-cache/ +_compile.log +_gamepid.txt +_run_err.txt +_run_out.txt diff --git a/assets/models/building_floor.glb b/assets/models/building_floor.glb index 8df2c37..8ed8e9b 100644 Binary files a/assets/models/building_floor.glb and b/assets/models/building_floor.glb differ diff --git a/assets/models/building_wall.glb b/assets/models/building_wall.glb index eb9c81a..cf8de26 100644 Binary files a/assets/models/building_wall.glb and b/assets/models/building_wall.glb differ diff --git a/assets/models/calib_rig.glb b/assets/models/calib_rig.glb new file mode 100644 index 0000000..9d86ec6 Binary files /dev/null and b/assets/models/calib_rig.glb differ diff --git a/assets/models/house.glb b/assets/models/house.glb new file mode 100644 index 0000000..76e88fd Binary files /dev/null and b/assets/models/house.glb differ diff --git a/assets/models/prop_barrel.glb b/assets/models/prop_barrel.glb index ff5ed7d..6c1603f 100644 Binary files a/assets/models/prop_barrel.glb and b/assets/models/prop_barrel.glb differ diff --git a/assets/models/prop_bed.glb b/assets/models/prop_bed.glb index ac14002..4ae6001 100644 Binary files a/assets/models/prop_bed.glb and b/assets/models/prop_bed.glb differ diff --git a/assets/models/prop_chair.glb b/assets/models/prop_chair.glb index 16d1cd6..09d89f3 100644 Binary files a/assets/models/prop_chair.glb and b/assets/models/prop_chair.glb differ diff --git a/assets/models/prop_crate.glb b/assets/models/prop_crate.glb index ea54a53..4af2a41 100644 Binary files a/assets/models/prop_crate.glb and b/assets/models/prop_crate.glb differ diff --git a/assets/models/prop_flower.glb b/assets/models/prop_flower.glb new file mode 100644 index 0000000..49bf23c Binary files /dev/null and b/assets/models/prop_flower.glb differ diff --git a/assets/models/prop_grasstuft.glb b/assets/models/prop_grasstuft.glb new file mode 100644 index 0000000..d683ea7 Binary files /dev/null and b/assets/models/prop_grasstuft.glb differ diff --git a/assets/models/prop_table.glb b/assets/models/prop_table.glb index f359b69..696f668 100644 Binary files a/assets/models/prop_table.glb and b/assets/models/prop_table.glb differ diff --git a/assets/models/prop_tree.glb b/assets/models/prop_tree.glb index 392bf9d..f0d5ad1 100644 Binary files a/assets/models/prop_tree.glb and b/assets/models/prop_tree.glb differ diff --git a/assets/models/terrain_hills.glb b/assets/models/terrain_hills.glb index 0aebe58..328a31c 100644 Binary files a/assets/models/terrain_hills.glb and b/assets/models/terrain_hills.glb differ diff --git a/package.json b/package.json index 1b4c79b..c3813a5 100644 --- a/package.json +++ b/package.json @@ -12,5 +12,10 @@ }, "dependencies": { "bloom": "file:../engine/" + }, + "perry": { + "allow": { + "nativeLibrary": ["bloom", "bloom/core", "bloom/scene", "bloom/physics"] + } } } diff --git a/src/generated/terrain.ts b/src/generated/terrain.ts index b29ee09..90d2a0b 100644 --- a/src/generated/terrain.ts +++ b/src/generated/terrain.ts @@ -165,34 +165,34 @@ export const TERRAIN_HEIGHTS: number[] = [ 0.4048, 0.2353, 0.1148, 0.0397, 0.0030, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0040, 0.0241, 0.0366, 0.0424, 0.0433, 0.0420, 0.0411, 0.0411, 0.0430, 0.0506, 0.0631, 0.0795, 0.0984, 0.1187, 0.1387, 0.1570, 0.1721, 0.1829, 0.1882, 0.1869, 0.1786, - 0.5884, 0.7254, 0.8686, 1.0130, 1.1523, 1.2796, 1.3879, 1.4706, 1.5220, 1.5383, 1.5174, 1.4599, 1.3685, 1.2482, 1.0649, 0.7729, - 0.5244, 0.3257, 0.1782, 0.0789, 0.0209, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, - 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0120, 0.0227, 0.0269, 0.0261, 0.0227, - 0.0196, 0.0198, 0.0237, 0.0330, 0.0477, 0.0668, 0.0889, 0.1126, 0.1362, 0.1582, 0.1772, 0.1918, 0.2008, 0.2035, 0.1990, 0.1869, - 0.6504, 0.7973, 0.9510, 1.1060, 1.2562, 1.3942, 1.5127, 1.6048, 1.6645, 1.6873, 1.6712, 1.6162, 1.5252, 1.4030, 1.2565, 0.9591, - 0.6771, 0.4457, 0.2676, 0.1402, 0.0574, 0.0104, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, - 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0050, 0.0137, 0.0158, 0.0129, 0.0072, 0.0016, - -0.0006, 0.0036, 0.0149, 0.0323, 0.0546, 0.0806, 0.1083, 0.1362, 0.1624, 0.1854, 0.2038, 0.2165, 0.2225, 0.2212, 0.2124, 0.1958, - 0.7102, 0.8669, 1.0309, 1.1968, 1.3583, 1.5079, 1.6379, 1.7410, 1.8106, 1.8421, 1.8327, 1.7824, 1.6936, 1.5714, 1.4224, 1.1875, - 0.8692, 0.6018, 0.3892, 0.2299, 0.1186, 0.0470, 0.0055, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, - 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0020, 0.0082, 0.0080, 0.0027, -0.0056, -0.0139, -0.0188, - -0.0169, -0.0045, 0.0161, 0.0425, 0.0733, 0.1064, 0.1397, 0.1714, 0.1994, 0.2223, 0.2388, 0.2479, 0.2493, 0.2427, 0.2281, 0.2060, - 0.7672, 0.9333, 1.1076, 1.2847, 1.4581, 1.6202, 1.7629, 1.8784, 1.9599, 2.0019, 2.0014, 1.9579, 1.8734, 1.7528, 1.6029, 1.4322, - 1.1068, 0.8002, 0.5494, 0.3543, 0.2106, 0.1109, 0.0460, 0.0063, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, - 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0016, 0.0051, 0.0024, -0.0055, -0.0165, -0.0278, -0.0358, -0.0366, - -0.0259, -0.0019, 0.0293, 0.0661, 0.1062, 0.1470, 0.1862, 0.2212, 0.2503, 0.2717, 0.2846, 0.2884, 0.2833, 0.2695, 0.2476, 0.2185, - 0.8205, 0.9958, 1.1803, 1.3688, 1.5546, 1.7299, 1.8864, 2.0159, 2.1109, 2.1654, 2.1759, 2.1411, 2.0629, 1.9458, 1.7964, 1.6232, - 1.3950, 1.0463, 0.7538, 0.5191, 0.3393, 0.2077, 0.1159, 0.0547, 0.0150, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, - 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0027, 0.0032, -0.0022, -0.0127, -0.0266, -0.0411, -0.0525, -0.0564, -0.0487, - -0.0230, 0.0134, 0.0571, 0.1057, 0.1564, 0.2058, 0.2509, 0.2891, 0.3182, 0.3368, 0.3442, 0.3407, 0.3268, 0.3036, 0.2725, 0.2348, - 0.8694, 1.0533, 1.2477, 1.4475, 1.6460, 1.8352, 2.0065, 2.1511, 2.2611, 2.3299, 2.3532, 2.3292, 2.2591, 2.1471, 1.9996, 1.8251, - 1.6331, 1.3440, 1.0070, 0.7292, 0.5093, 0.3420, 0.2196, 0.1332, 0.0740, 0.0341, 0.0073, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, - 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0019, 0.0044, 0.0017, -0.0065, -0.0199, -0.0368, -0.0547, -0.0697, -0.0774, -0.0730, -0.0484, - -0.0075, 0.0434, 0.1016, 0.1640, 0.2267, 0.2857, 0.3373, 0.3783, 0.4065, 0.4207, 0.4206, 0.4072, 0.3820, 0.3470, 0.3044, 0.2563, - 0.9125, 1.1044, 1.3083, 1.5192, 1.7303, 1.9336, 2.1203, 2.2810, 2.4072, 2.4916, 2.5292, 2.5176, 2.4574, 2.3520, 2.2079, 2.0332, - 1.8377, 1.6315, 1.3108, 0.9869, 0.7236, 0.5168, 0.3600, 0.2446, 0.1621, 0.1042, 0.0643, 0.0370, 0.0185, 0.0066, 0.0000, 0.0000, - 0.0000, 0.0000, 0.0035, 0.0071, 0.0087, 0.0069, 0.0005, -0.0111, -0.0276, -0.0478, -0.0692, -0.0882, -0.1003, -0.1000, -0.0788, -0.0349, - 0.0220, 0.0896, 0.1646, 0.2428, 0.3193, 0.3891, 0.4477, 0.4914, 0.5178, 0.5258, 0.5161, 0.4902, 0.4510, 0.4014, 0.3447, 0.2839, + 0.5884, 0.7254, 0.1115, 0.1788, 0.2438, 0.3031, 0.3536, 0.3922, 0.4162, 0.4237, 0.4140, 0.3872, 0.3446, 0.2885, 0.2030, 0.0669, + -0.0490, -0.1417, -0.2104, -0.2567, -0.2838, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, + -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2935, -0.2879, -0.2829, -0.2810, -0.2814, -0.2830, + -0.2844, -0.2843, -0.2825, -0.2781, -0.2713, -0.2624, -0.2521, -0.2410, -0.2300, -0.2198, -0.2109, -0.2041, -0.1999, -0.1987, -0.2008, 0.1869, + 0.6504, 0.7973, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, 0.1958, + 0.7102, 0.8669, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, 0.2060, + 0.7672, 0.9333, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, 0.2185, + 0.8205, 0.9958, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, 0.2348, + 0.8694, 1.0533, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, + -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, -0.5500, 0.2563, + 0.9125, 1.1044, 0.4639, 0.5789, 0.6941, 0.8050, 0.9068, 0.9945, 1.0634, 1.1094, 1.1300, 1.1236, 1.0907, 1.0333, 0.9546, 0.8594, + 0.7527, 0.6402, 0.4652, 0.2885, 0.1448, 0.0320, -0.0535, -0.1165, -0.1615, -0.1931, -0.2148, -0.2298, -0.2398, -0.2464, -0.2499, -0.2499, + -0.2499, -0.2499, -0.2480, -0.2461, -0.2452, -0.2462, -0.2497, -0.2560, -0.2650, -0.2760, -0.2877, -0.2980, -0.3046, -0.3045, -0.2929, -0.2690, + -0.2379, -0.2011, -0.1601, -0.1175, -0.0758, -0.0377, -0.0057, 0.0182, 0.0325, 0.0369, 0.0316, 0.0175, -0.0039, -0.0309, -0.0619, 0.2839, 0.9487, 1.1476, 1.3601, 1.5813, 1.8047, 2.0220, 2.2240, 2.4011, 2.5442, 2.6452, 2.6984, 2.7005, 2.6515, 2.5543, 2.4149, 2.2414, 2.0434, 1.8312, 1.6150, 1.2922, 0.9825, 0.7330, 0.5379, 0.3897, 0.2799, 0.2001, 0.1431, 0.1029, 0.0748, 0.0556, 0.0427, 0.0345, 0.0294, 0.0260, 0.0229, 0.0184, 0.0112, 0.0001, -0.0156, -0.0358, -0.0596, -0.0848, -0.1082, -0.1253, -0.1303, -0.1138, -0.0695, -0.0090, diff --git a/src/main.ts b/src/main.ts index 53f4a60..4989f91 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,12 +8,22 @@ import { vec3, isKeyPressed, Key, Vec3, injectKeyDown, injectKeyUp, disableCursor, enableCursor, takeScreenshot, - loadModel, drawModel, loadModelAnimation, updateModelAnimation, - createMesh, compileMaterial, drawMeshWithMaterial, + loadModel, drawModel, drawModelRotated, loadModelAnimation, updateModelAnimation, + createMesh, compileMaterial, compileRefractiveMaterial, drawMeshWithMaterial, + genMeshCube, createPlanarReflection, setMaterialReflectionProbe, initAudio, loadSound, playSound, setSoundVolume, loadMusic, playMusic, updateMusicStream, setMusicVolume, } from 'bloom'; -import { setVignette, setFilmGrain } from 'bloom/core'; +import { + setVignette, setFilmGrain, + setQualityPreset, QualityPreset, + setShadowsEnabled, setSsaoEnabled, setSsaoIntensity, setSsaoRadius, + setBloomEnabled, setBloomIntensity, setTaaEnabled, + setAutoExposure, setManualExposure, setAutoExposureKey, setEnvIntensity, + setSsrEnabled, setSsgiEnabled, setSsgiIntensity, setFog, setSunShafts, + setTonemap, Tonemap, setWind, +} from 'bloom/core'; +import { setProceduralSky, setSunDirection } from 'bloom'; import { addPointLight } from 'bloom/scene'; import { createWorld, step as stepPhysics, @@ -30,6 +40,28 @@ initWindow(1024, 640, 'Bloom Shooter'); setTargetFPS(60); initInput(); +// ---- Loading screen ------------------------------------------------------- +// The heavy startup work (GLB model parse/upload, the tessellated water-mesh +// scratch upload, audio decode) runs synchronously before the game loop, so +// the window would otherwise sit frozen-black for several seconds. Render a +// progress frame between the major steps so startup shows a bar. +function drawLoading(progress: number, label: string): void { + beginDrawing(); + clearBackground({ r: 16, g: 18, b: 26, a: 255 }); + const sw = getScreenWidth(), sh = getScreenHeight(); + const title = 'BLOOM SHOOTER'; + drawText(title, (sw - measureText(title, 34)) / 2, sh * 0.30, 34, + { r: 222, g: 232, b: 244, a: 255 }); + const bw = 440, bh = 22, bx = (sw - bw) / 2, by = sh * 0.55; + const p = progress < 0 ? 0 : (progress > 1 ? 1 : progress); + drawText(label, bx, by - 26, 15, { r: 165, g: 178, b: 196, a: 255 }); + drawRect(bx - 2, by - 2, bw + 4, bh + 4, { r: 70, g: 78, b: 92, a: 255 }); + drawRect(bx, by, bw, bh, { r: 28, g: 31, b: 40, a: 255 }); + drawRect(bx, by, bw * p, bh, { r: 96, g: 176, b: 226, a: 255 }); + endDrawing(); +} +drawLoading(0.05, 'Starting up…'); + // ---- M8 polish: audio ----------------------------------------------------- initAudio(); const sfxFire = loadSound('assets/sounds/rifle_fire.wav'); @@ -41,6 +73,7 @@ setSoundVolume(sfxPickup, 0.8); const musicAmbient = loadMusic('assets/sounds/ambient.ogg'); setMusicVolume(musicAmbient, 0.35); playMusic(musicAmbient); +drawLoading(0.20, 'Building world…'); const physics = createWorld({ gravity: vec3(0, -20, 0) }); // Make NON_MOVING (static) and MOVING (character/dynamic) collide. @@ -105,6 +138,7 @@ for (let i = 0; i < W.COLLIDER_COUNT; i++) { const MESH_TINT_R = [150, 196, 120, 130]; const MESH_TINT_G = [148, 168, 90, 95]; const MESH_TINT_B = [140, 130, 70, 80]; +drawLoading(0.35, 'Loading models…'); const meshModelHandles = new Array(W.UNIQUE_MODEL_COUNT); for (let i = 0; i < W.UNIQUE_MODEL_COUNT; i++) { meshModelHandles[i] = W.MODEL_IS_BOX[i] === 1 ? 0 : loadModel(W.UNIQUE_MODELS[i]); @@ -136,11 +170,13 @@ createPlayer(physics, spawnPos); // CAM[2] camX CAM[7] tgtZ // CAM[3] camY CAM[8] initialised (0/1) // CAM[4] camZ -const CAM = [spawnYaw, 0.35, 0, 0, 0, 0, 0, 0, 0]; -const TP_PITCH_MIN = -0.25; -const TP_PITCH_MAX = 1.20; -const TP_ORBIT_DIST = 6.0; +const CAM = [spawnYaw, 0.3, 0, 0, 0, 0, 0, 0, 0]; +// Third-person orbit pitch limits (camera elevation about the player). +const TP_PITCH_MIN = -0.35; +const TP_PITCH_MAX = 1.05; +const TP_ORBIT_DIST = 6.0; // (unused in first-person) const TP_EYE_HEIGHT = 1.4; +const FP_EYE_HEIGHT = 0.6; // eye above the capsule centre (playerPosition) const TP_SMOOTH = 10.0; const TP_FOVY = 70; @@ -150,6 +186,16 @@ const TP_FOVY = 70; // "away from the camera" (classic 3rd-person over-the-shoulder feel). const mdlPlayer = loadModel('assets/models/player_bsuit.glb'); const animPlayer = loadModelAnimation('assets/models/player_bsuit.glb'); +// The house: all "building"-tagged box colliders baked into ONE stone-textured +// mesh (tools/build-props.ts makeHouse), vertices already in world space — so +// it draws with a single drawModel at the origin. Replaces the flat-grey +// drawCube placeholders; the boxes remain invisible physics colliders. +const mdlHouse = loadModel('assets/models/house.glb'); +// PBR calibration rig (grey-albedo / roughness / metallic sphere grid). Only +// drawn + framed when VERIFY_CALIB is on, for objective exposure/BRDF checks. +const VERIFY_CALIB = false; +const CALIB_AT = vec3(0, 6, 0); +const mdlCalib = loadModel('assets/models/calib_rig.glb'); // human_bsuit animation indices (IQE declaration order): // 0 idle, 7 attack, 8 run, 12 walk. const PLAYER_ANIM_IDLE = 0; @@ -230,7 +276,178 @@ const MAT_TEST_INDS: number[] = [ 16,17,18, 16,18,19, 20,21,22, 20,22,23, ]; -const matTestMesh = createMesh(MAT_TEST_VERTS, MAT_TEST_INDS); +// Phase 1c material smoke-test cube. Disabled on the Windows/Perry-0.5.1171 +// path: `createMesh` passes a number[] straight into `bloom_create_mesh`'s +// `i64` vertex/index-pointer params, but Perry 0.5.1171 tightened the native +// ABI so an `i64` parameter must be a safe-integer number, not an array +// (TypeError: "Expected safe integer for native i64 parameter"). The engine +// needs to migrate this FFI to Perry's `buffer+len` descriptor (or the +// physics-style scratch-buffer push) before the material path works again. +// The cube is a dev bring-up artifact, not gameplay — gate it off. +const MAT_SMOKE = false; +const matTestMesh = MAT_SMOKE ? createMesh(MAT_TEST_VERTS, MAT_TEST_INDS) : 0; + +// Build a tessellated flat grid plane (sx × sz, centred at origin, y=0) for +// the water surface. The grid is dense enough for the vertex shader to +// displace it into real waves (fixing the flat-silhouette look). 12 floats +// per vertex: pos3, nrm3, col4, uv2. Uses the (now array-free) createMesh. +function buildWaterGrid(sx: number, sz: number) { + const NX = Math.max(2, Math.ceil(sx / 0.55)); + const NZ = Math.max(2, Math.ceil(sz / 0.55)); + const VX = NX + 1, VZ = NZ + 1; + const verts = new Array(VX * VZ * 12); + let o = 0; + for (let iz = 0; iz < VZ; iz++) { + for (let ix = 0; ix < VX; ix++) { + const u = ix / NX, v = iz / NZ; + verts[o] = (u - 0.5) * sx; verts[o + 1] = 0; verts[o + 2] = (v - 0.5) * sz; + verts[o + 3] = 0; verts[o + 4] = 1; verts[o + 5] = 0; + verts[o + 6] = 1; verts[o + 7] = 1; verts[o + 8] = 1; verts[o + 9] = 1; + verts[o + 10] = u; verts[o + 11] = v; + o = o + 12; + } + } + const inds = new Array(NX * NZ * 6); + let io = 0; + for (let iz = 0; iz < NZ; iz++) { + for (let ix = 0; ix < NX; ix++) { + const a = iz * VX + ix, b = a + 1, c = a + VX, d = c + 1; + inds[io] = a; inds[io + 1] = c; inds[io + 2] = b; + inds[io + 3] = b; inds[io + 4] = c; inds[io + 5] = d; + io = io + 6; + } + } + return createMesh(verts, inds); +} + +// ---- Reflective water (planar-reflection material) ------------------------ +// Replaces the ~1800-cube procedural river with one flat plane per segment, +// shaded by a custom WGSL material that samples a planar-reflection probe (the +// engine's per-frame mirror render of the world above the surface) with +// Fresnel blending, procedural wave normals, and a sun glint. The plane mesh +// comes from genMeshCube (scalar args — avoids the createMesh i64 ABI issue), +// and drawMeshWithMaterial submits it (all-f64 ABI). +const WATER_WGSL = + '#include "material_abi.wgsl"\n' + + 'struct VsOut { @builtin(position) pos: vec4, @location(0) wpos: vec3, @location(1) uv: vec2 };\n' + + // Macro wave height (metres) — three directional swells. Displaces the + // tessellated grid vertices so the surface physically undulates (wavy + // silhouette + moving edges), not just a normal-mapped flat plane. + 'fn wheight(p: vec2, t: f32) -> f32 {\n' + + ' return 0.12 * sin(p.x * 0.5 + t * 1.1)\n' + + ' + 0.06 * sin((p.x * 0.9 + p.y * 0.6) + t * 1.9)\n' + + ' + 0.035 * sin((p.x * 2.3 - p.y * 1.1) + t * 3.3);\n' + + '}\n' + + '@vertex fn vs_main(in: VertexInput) -> VsOut {\n' + + ' var o: VsOut;\n' + + ' let wflat = (draw.model * vec4(in.position, 1.0)).xyz;\n' + + ' let disp = vec3(in.position.x, in.position.y + wheight(wflat.xz, frame.time), in.position.z);\n' + + ' let wd = draw.model * vec4(disp, 1.0);\n' + + ' o.wpos = wd.xyz;\n' + + ' o.uv = in.uv;\n' + + ' o.pos = draw.mvp * vec4(disp, 1.0);\n' + + ' return o;\n' + + '}\n' + + // Procedural surface normal from summed directional sine waves (analytic + // derivative -> perturbed normal; cheap, no normal-map texture needed). + 'fn wnorm(p: vec2, t: f32) -> vec3 {\n' + + // Three octaves: slow swell + medium chop + fine fast ripple. Strong + // slope (×0.30) so the surface reads as lively water on a flat plane. + ' let dx = 0.5 * cos(p.x * 0.5 + t * 1.1) + 0.7 * cos((p.x * 0.9 + p.y * 0.6) + t * 1.9) + 0.5 * cos((p.x * 2.3 - p.y * 1.1) + t * 3.3);\n' + + ' let dz = 0.5 * cos(p.y * 0.7 - t * 1.3) + 0.7 * cos((p.x * 0.6 + p.y * 0.9) + t * 1.9) + 0.5 * cos((p.x * 1.1 + p.y * 2.3) + t * 3.0);\n' + + ' return normalize(vec3(-dx * 0.30, 1.0, -dz * 0.30));\n' + + '}\n' + + // Analytic sky reflection. The engine's procedural sky renders via a + // separate LUT pass and never lands in env_tex, and the planar-reflection + // probe only captures material-system draws (this game is immediate-mode), + // so there is nothing real to mirror. Instead we reflect a believable sky + // dome built from the reflected ray R: zenith→horizon gradient + a sharp + // sun disk + soft glow, tinted by the engine's actual sun colour/intensity. + 'fn sky_refl(R: vec3, sun_dir: vec3, sun_col: vec3, sun_int: f32) -> vec3 {\n' + + ' let up = clamp(R.y, 0.0, 1.0);\n' + + ' let zenith = vec3(0.13, 0.27, 0.52);\n' + + ' let horizon = vec3(0.55, 0.68, 0.82);\n' + + ' var sky = mix(horizon, zenith, pow(up, 0.55));\n' + + ' let sd = max(dot(R, normalize(sun_dir)), 0.0);\n' + + ' sky = sky + sun_col * (pow(sd, 900.0) * 6.0 + pow(sd, 12.0) * 0.25) * max(sun_int, 0.4);\n' + + ' return sky;\n' + + '}\n' + + // Refractive profile: sample the scene (riverbed) BEHIND the surface and + // distort it by the wave normal — real refraction (the bottom wobbles). + // Tint it by water depth (scene-depth → Beer-Lambert: shallow clear, deep + // murky blue), then blend a Fresnel planar reflection + sun glint + foam. + // Composited in-shader (opaque output) → reads as real water, not a flat + // tinted pane. + '@fragment fn fs_main(in: VsOut) -> TranslucentOut {\n' + + ' var out: TranslucentOut;\n' + + ' let t = frame.time;\n' + + ' let N = wnorm(in.wpos.xz, t);\n' + + ' let V = normalize(view.camera_pos.xyz - in.wpos);\n' + + ' let ndv = max(dot(N, V), 0.0001);\n' + + ' let screen_uv = in.pos.xy / vec2(frame.screen_resolution);\n' + + // Refraction — the riverbed, displaced by the wave normal. + ' let refr_uv = clamp(screen_uv + N.xz * 0.04, vec2(0.002), vec2(0.998));\n' + + ' let bottom = textureSample(scene_color_tex, scene_color_samp, refr_uv).rgb;\n' + + // Water column thickness from scene depth → absorption. textureLoad avoids + // depth-sampler issues; clamp keeps it plausible if depth is imprecise. + ' let dsz = vec2(textureDimensions(scene_depth_tex));\n' + + ' let bz = textureLoad(scene_depth_tex, vec2(screen_uv * dsz), 0);\n' + + ' let thick = max(0.0, linearize_depth(bz) - linearize_depth(in.pos.z));\n' + + // Beer-Lambert: shallow water stays clear (bed visible near the banks), + // deep water tints toward murky green-blue. Floor near 0 so the refracted + // riverbed reads through; ceiling < 1 so the deepest water keeps some bed. + ' let absorb = clamp(1.0 - exp(-thick * 0.85), 0.04, 0.88);\n' + + ' let deep = vec3(0.02, 0.11, 0.13);\n' + + ' let body = mix(bottom, deep, absorb);\n' + + // Reflected ray → analytic sky. Fresnel (water F0≈0.02, Schlick) ramps the + // sky in hard at grazing angles, so the surface goes bright + sky-coloured + // toward the far bank and stays clear/see-through looking straight down. + ' let R = reflect(-V, N);\n' + + ' let sky = sky_refl(R, view.sun_dir.xyz, view.sun_color.rgb, view.sun_dir.w);\n' + + // Planar reflection probe: the actual mirrored scene (trees / house). The + // probe clears to alpha 0 and reflected geometry writes alpha 1, so blend the + // probe over the analytic sky by its alpha — real reflections where geometry + // exists, the sky dome everywhere else. Wave normal perturbs the lookup. + ' let prefl = textureSample(planar_reflection_tex, planar_reflection_samp, clamp(screen_uv + N.xz * 0.03, vec2(0.001), vec2(0.999)));\n' + + ' let refl = mix(sky, prefl.rgb, clamp(prefl.a, 0.0, 1.0));\n' + + ' let fres = clamp(0.02 + 0.98 * pow(1.0 - ndv, 5.0), 0.0, 1.0);\n' + + // Tight specular sun glint on top of the Fresnel sky reflection. + // view.sun_dir is direction-TO-sun, so L = +sun_dir (was negated to + // compensate for the old below-horizon SUN_DIR; fixed now). + ' let L = normalize(view.sun_dir.xyz);\n' + + ' let H = normalize(L + V);\n' + + ' let spec = pow(max(dot(N, H), 0.0), 400.0) * max(view.sun_dir.w, 0.6) * 1.4;\n' + + ' var color = mix(body, refl, fres) + vec3(1.0, 0.97, 0.9) * spec;\n' + + // Shoreline foam. + ' let edge = min(min(in.uv.x, 1.0 - in.uv.x), min(in.uv.y, 1.0 - in.uv.y));\n' + + ' let foam_band = smoothstep(0.07, 0.0, edge);\n' + + ' let foam = foam_band * foam_band * (0.6 + 0.4 * sin(in.wpos.x * 6.0 + in.wpos.z * 5.0 + t * 3.0));\n' + + ' color = mix(color, vec3(0.85, 0.92, 0.97), foam * 0.6);\n' + + ' out.hdr = vec4(color, 1.0);\n' + + ' return out;\n' + + '}\n'; +const waterMat = compileRefractiveMaterial(WATER_WGSL); +// ONE continuous river plane spanning all segments + the carved channel. The +// authored world has 6 short segments at alternating z (11/13) which rendered +// as disconnected rectangles with gaps; a single plane over their bounding +// span reads as one connected ribbon that fills the carved channel. +let riverMinX = 1e9, riverMaxX = -1e9, waterYSum = 0; +for (let i = 0; i < W.WATER_COUNT; i++) { + const lo = W.WATER_CX[i] - W.WATER_SX[i] * 0.5; + const hi = W.WATER_CX[i] + W.WATER_SX[i] * 0.5; + if (lo < riverMinX) riverMinX = lo; + if (hi > riverMaxX) riverMaxX = hi; + waterYSum = waterYSum + W.WATER_CY[i]; +} +const waterY = W.WATER_COUNT > 0 ? waterYSum / W.WATER_COUNT : 0.05; +const RIVER_CX = (riverMinX + riverMaxX) * 0.5; +const RIVER_W = riverMaxX - riverMinX; +const RIVER_CZ = 12.0; // carved-channel centre (see build-terrain.ts) +const RIVER_DEPTH = 5.0; // fits the channel bed (z ≈ 9.4–14.6) +drawLoading(0.65, 'Building water…'); +const waterMesh = buildWaterGrid(RIVER_W, RIVER_DEPTH); +const waterProbe = createPlanarReflection(waterY, 0, 1, 0, 512); +if (waterMat > 0) setMaterialReflectionProbe(waterMat, waterProbe); // ---- Unvanquished aliens (5 kinds, M3 model + M5 AI + M6 pool) ------------ // Each kind has its own GLB model and stat line. Kinds and models line up @@ -242,6 +459,7 @@ const matTestMesh = createMesh(MAT_TEST_VERTS, MAT_TEST_INDS); // 4 = tyrant — boss tier; rare, big, tanky const KIND_COUNT = 5; const KIND_NAME = ['DRETCH', 'MANTIS', 'MARAUDER', 'DRAGOON', 'TYRANT']; +drawLoading(0.85, 'Loading enemies…'); const mdlAliens = [ loadModel('assets/models/enemy_dretch.glb'), loadModel('assets/models/enemy_mantis.glb'), @@ -336,6 +554,109 @@ function countAlive(): number { return c; } +// Sample the terrain heightfield (same data the Jolt heightfield collider uses) +// so enemies walk ON the ground instead of a flat y=0 plane — otherwise they +// sink into hills and appear to float on top of the river. Over the carved +// river channel the result is clamped to WADE_FLOOR so crossing aliens wade at +// the water surface rather than sinking out of sight to the −0.55 bed. +const WADE_FLOOR = -0.2; +function terrainHeight(x: number, z: number): number { + const ix = Math.round((x - T.TERRAIN_ORIGIN_X) / T.TERRAIN_CELL_SIZE); + const iz = Math.round((z - T.TERRAIN_ORIGIN_Z) / T.TERRAIN_CELL_SIZE); + if (ix < 0 || iz < 0 || ix >= T.TERRAIN_SAMPLE_COUNT || iz >= T.TERRAIN_SAMPLE_COUNT) return 0; + return T.TERRAIN_HEIGHTS[iz * T.TERRAIN_SAMPLE_COUNT + ix]; +} +function enemyGroundY(x: number, z: number): number { + const h = terrainHeight(x, z); + return h < WADE_FLOOR ? WADE_FLOOR : h; +} + +// ---- Scattered ground foliage -------------------------------------------- +// Deterministic grass-tuft scatter across the play field (hash-jittered grid), +// placed on the terrain surface, skipping the river channel, the house +// footprint, and a clear radius around spawn. Drawn each frame; the tufts are +// alpha-cutout foliage so they sway in the wind via the engine's scene shader. +const mdlGrassTuft = loadModel('assets/models/prop_grasstuft.glb'); +const mdlFlower = loadModel('assets/models/prop_flower.glb'); +const TUFT_MAX = 320; +const tuftX = new Array(TUFT_MAX); +const tuftY = new Array(TUFT_MAX); +const tuftZ = new Array(TUFT_MAX); +const tuftS = new Array(TUFT_MAX); +const tuftR = new Array(TUFT_MAX); +const tuftFlower = new Array(TUFT_MAX); // 1 = wildflower, 0 = grass +let tuftCount = 0; +{ + const hashf = (a: number, b: number): number => { + let h = ((a * 374761393 + b * 668265263) >>> 0); + h = (((h ^ (h >>> 13)) >>> 0) * 1274126177) >>> 0; + return ((h ^ (h >>> 16)) >>> 0) / 4294967295; + }; + const GRID = 26; + const SPAN = 70; // metres across, centred on origin + const step = SPAN / GRID; + for (let gz = 0; gz < GRID; gz++) { + for (let gx = 0; gx < GRID; gx++) { + if (tuftCount >= TUFT_MAX) break; + // ~35% thinning for natural clumping rather than a regular lawn. + if (hashf(gx + 7, gz + 13) < 0.35) continue; + const jx = hashf(gx, gz) - 0.5; + const jz = hashf(gx + 101, gz + 57) - 0.5; + const wx = -SPAN / 2 + (gx + 0.5 + jx * 0.95) * step; + const wz = -SPAN / 2 + (gz + 0.5 + jz * 0.95) * step; + const h = terrainHeight(wx, wz); + if (h < 0.15) continue; // river / wet ground + if (wx > -29 && wx < -13 && wz > -19 && wz < -9) continue; // house footprint + const sdx = wx - spawnPos.x, sdz = wz - spawnPos.z; + if (sdx * sdx + sdz * sdz < 9) continue; // clear spawn + tuftX[tuftCount] = wx; tuftY[tuftCount] = h; tuftZ[tuftCount] = wz; + // ~1 in 6 is a wildflower clump (smaller); rest are grass tufts. + const flower = hashf(gx + 41, gz + 83) < 0.17 ? 1 : 0; + tuftFlower[tuftCount] = flower; + tuftS[tuftCount] = flower + ? 0.8 + hashf(gx + 31, gz + 19) * 0.7 + : 0.75 + hashf(gx + 31, gz + 19) * 1.15; + tuftR[tuftCount] = hashf(gx + 5, gz + 71) * 6.2832; + tuftCount++; + } + } +} + +// Perimeter treeline — a ring of extra trees around the playfield to frame the +// arena, give the background depth, and hide the hard terrain edge that an open +// field otherwise ends on. Skips the river band and the house. Same hash so the +// layout is stable build-to-build. +const mdlTreeRing = loadModel('assets/models/prop_tree.glb'); +const RTREE_MAX = 64; +const rtX = new Array(RTREE_MAX); +const rtY = new Array(RTREE_MAX); +const rtZ = new Array(RTREE_MAX); +const rtS = new Array(RTREE_MAX); +const rtR = new Array(RTREE_MAX); +let rtCount = 0; +{ + const hashf = (a: number, b: number): number => { + let h = ((a * 374761393 + b * 668265263) >>> 0); + h = (((h ^ (h >>> 13)) >>> 0) * 1274126177) >>> 0; + return ((h ^ (h >>> 16)) >>> 0) / 4294967295; + }; + const N = 60; + for (let i = 0; i < N; i++) { + if (rtCount >= RTREE_MAX) break; + const ang = (i / N) * 6.2832 + (hashf(i, 2) - 0.5) * 0.12; + const rad = 27 + hashf(i, 3) * 9; // 27–36 m ring + const wx = Math.cos(ang) * rad; + const wz = Math.sin(ang) * rad; + if (wz > 7 && wz < 17) continue; // river band + if (wx > -31 && wx < -11 && wz > -21 && wz < -7) continue; // house + const h = terrainHeight(wx, wz); + rtX[rtCount] = wx; rtY[rtCount] = h > 0 ? h : 0; rtZ[rtCount] = wz; + rtS[rtCount] = 0.9 + hashf(i, 9) * 0.8; + rtR[rtCount] = hashf(i, 13) * 6.2832; + rtCount++; + } +} + function findDormantSlot(kind: number): number { for (let j = 0; j < BODIES_PER_KIND; j++) { const i = kind * BODIES_PER_KIND + j; @@ -350,8 +671,8 @@ function spawnEnemy(): void { if (slot < 0) return; // all bodies of this kind busy; retry next tick const sp = waveSpawned % 4; enX[slot] = spawnerX[sp]; - enY[slot] = 0; enZ[slot] = spawnerZ[sp]; + enY[slot] = enemyGroundY(enX[slot], enZ[slot]); enHP[slot] = KIND_HP[kind]; enAlive[slot] = 1; enAttackCD[slot] = 0; @@ -489,8 +810,61 @@ disableCursor(); // ---- M8 polish: post-FX --------------------------------------------------- // Called once at startup — these are cheap, always-on stylistic passes. -setVignette(0.4, 0.55); // darken frame edges -setFilmGrain(0.06); // very subtle noise +setVignette(0.45, 0.5); // cinematic edge darkening +setFilmGrain(0.05); // very subtle noise + +// ---- High-end rendering pipeline (UE-class), art-directed ----------------- +// Full deferred stack + procedural atmosphere. The trick to not washing the +// saturated arena out to grey: keep the sky's image-based ambient modest +// (setEnvIntensity) and a low cool fill, then let a strong WARM directional +// sun carry the lighting and cast the shadows. Manual exposure (not auto) so +// the bright sky doesn't drag the scene's exposure around. +// SUN_DIR is the DIRECTION TO THE SUN (unit-ish). The engine's scene diffuse +// (max(dot(N, light_dir),0)), shadow cascade fit (light placed at center + +// light_dir·d), and the sky-view LUT (mu_s = sun.y) ALL treat this as +// direction-to-sun, so it must point UP (y>0) for daylight. The previous value +// pointed DOWN (y=-0.30) → engine thought the sun was below the horizon: ground +// got no direct sun (flat ambient look) and shadows were fit from a grazing/ +// below-horizon light ("weird shadows"). Now upper +x/+z, ~33° elevation. +const SUN_DIR_X = 0.55, SUN_DIR_Y = 0.58, SUN_DIR_Z = 0.42; +setQualityPreset(QualityPreset.High); +setShadowsEnabled(true); +setSsaoEnabled(true); +setSsaoIntensity(1.15); +setSsaoRadius(0.9); +setBloomEnabled(true); +setTaaEnabled(true); +setSsrEnabled(true); +setSsgiEnabled(true); +setSsgiIntensity(0.4); +// Real procedural atmosphere. The earlier grey wash-out came from the sky's +// image-based ambient flooding every surface — keep env-intensity very LOW so +// the warm key sun carries the lighting and colours stay saturated, while the +// sky still provides a proper gradient + sun disc in the background. No fog / +// sun-shafts (their haze was the other half of the wash-out). +setProceduralSky(true); +setSunDirection(vec3(SUN_DIR_X, SUN_DIR_Y, SUN_DIR_Z), 1.0); +// Gentle breeze — sways the alpha-cut foliage cards (engine reads this in the +// scene vertex shader for any alpha-cutout material). +setWind(1.0, 0.4, 0.4, 1.1); +setEnvIntensity(0.45); +setAutoExposure(true); +// AgX tonemap (new engine control): more filmic highlight roll-off + punchier +// colour than ACES — keeps the bright sky from greying the scene. Pair it with +// a slightly low auto-exposure key so the midtones stay saturated, and a touch +// of bloom on highlights (muzzle flash, pickups, sun-lit edges). +setTonemap(Tonemap.AgX); +setAutoExposureKey(0.24); +setBloomIntensity(0.07); +// Subtle scene-scale aerial haze. Matched to the sky horizon so distant +// geometry (and the far terrain edge) fades into the sky instead of ending +// on a hard line — adds depth without the grey wash-out that global fog used +// to cause (low density + ground-hugging height falloff keep near surfaces +// crisp). The engine's procedural-sky aerial LUT is km-scaled and negligible +// over this ~80 m arena, so this world-scale march carries the depth cue. +setFog(0.60, 0.71, 0.85, 0.011, 1.0, 0.10); +// (Sun-shafts tried here — even at low strength they flood this open arena +// with haze and wash the colours out, so they stay off.) // ---- Self-test harness ---------------------------------------------------- @@ -501,6 +875,7 @@ const SELFTEST = false; let testFrame = 0; +drawLoading(1.0, 'Ready'); while (!windowShouldClose()) { beginDrawing(); const dt = getDeltaTime(); @@ -556,8 +931,9 @@ while (!windowShouldClose()) { updatePlayerController(dt, input.moveX, input.moveZ, fwd, rgt, input.jump); } stepPhysics(physics, dt); - // Smooth orbit camera follow after physics step. - // Inline orbit-camera follow. + // Third-person (PUBG-style) orbit camera: behind + above the player at the + // mouse-driven yaw/pitch, smoothly following. The body (drawn below) faces + // the same yaw, so the mouse turns both camera and character together. { const pp0 = playerPosition(); const ya = CAM[0], pi = CAM[1]; @@ -598,6 +974,7 @@ while (!windowShouldClose()) { const move = step < dist ? step : dist; enX[i] = enX[i] + (dx / dist) * move; enZ[i] = enZ[i] + (dz / dist) * move; + enY[i] = enemyGroundY(enX[i], enZ[i]); setBodyPosition(enBody[i], vec3(enX[i], enY[i] + KIND_Y_OFF[k], enZ[i]), true); } else if (enAttackCD[i] <= 0) { @@ -790,10 +1167,36 @@ while (!windowShouldClose()) { clearBackground({ r: Math.floor(W.ENV_SKY_R * 255), g: Math.floor(W.ENV_SKY_G * 255), b: Math.floor(W.ENV_SKY_B * 255), a: 255 }); - setAmbientLight({ r: 120, g: 130, b: 160, a: 255 }, 0.35); - setDirectionalLight(vec3(-0.3, -0.9, -0.2), { r: 255, g: 245, b: 220, a: 255 }, 0.9); - - beginMode3D({ + // Low cool sky-fill ambient + a strong warm key sun (matched to the + // procedural-sky sun direction) for saturated colour and grounded shadows. + // Ambient kept low so cast shadows + SSAO read with real contrast instead + // of being flooded flat by skylight. + setAmbientLight({ r: 86, g: 100, b: 132, a: 255 }, 0.13); + setDirectionalLight(vec3(SUN_DIR_X, SUN_DIR_Y, SUN_DIR_Z), + { r: 255, g: 232, b: 198, a: 255 }, 2.5); + + // TEMP verification camera (off → normal third-person view). + const VERIFY_WATER = false; + const VERIFY_BEAUTY = false; + beginMode3D(VERIFY_CALIB ? { + position: vec3(3, 6.6, 9.5), + target: vec3(3, 6.0, -2.0), + up: vec3(0, 1, 0), + fovy: 58, + projection: 0, + } : VERIFY_BEAUTY ? { + position: vec3(-2, 6.0, 22.0), + target: vec3(13, 0.6, 2.0), + up: vec3(0, 1, 0), + fovy: 60, + projection: 0, + } : VERIFY_WATER ? { + position: vec3(6, 1.1, 16.0), + target: vec3(4, 0.1, 11.0), + up: vec3(0, 1, 0), + fovy: 60, + projection: 0, + } : { position: vec3(CAM[2], CAM[3], CAM[4]), target: vec3(CAM[5], CAM[6], CAM[7]), up: vec3(0, 1, 0), @@ -826,12 +1229,9 @@ while (!windowShouldClose()) { // Phase 1c smoke test — colour-pulsed cube in front of spawn, // rendered via the new material pipeline. Proves the compile → // submit → dispatch path works on a real frame. - if (matTest > 0) { + if (MAT_SMOKE && matTest > 0 && matTestMesh > 0) { drawMeshWithMaterial(matTest, matTestMesh, vec3(0, 3, 15), 3.0, { r: 255, g: 255, b: 255, a: 255 }); - } else { - drawText('material compile failed (handle=0)', 20, 100, 20, - { r: 255, g: 80, b: 80, a: 255 }); } // Static meshes — either drawModel for real GLBs, or coloured drawCube // for placeholder _gizmo_box.glb entries. MESH_CATEGORY drives the cube @@ -840,6 +1240,9 @@ while (!windowShouldClose()) { const mi = W.MESH_MODEL_IDX[i]; if (W.MODEL_IS_BOX[mi] === 1) { const c = W.MESH_CATEGORY[i]; + // Building boxes (category 1) are drawn collectively as the stone-textured + // house.glb below — skip their flat-grey placeholder cubes here. + if (c === 1) continue; const col = { r: MESH_TINT_R[c], g: MESH_TINT_G[c], b: MESH_TINT_B[c], a: 255 }; drawCube(vec3(W.MESH_X[i], W.MESH_Y[i], W.MESH_Z[i]), W.MESH_COLLIDER_HX[i] * 2, W.MESH_COLLIDER_HY[i] * 2, W.MESH_COLLIDER_HZ[i] * 2, @@ -850,62 +1253,29 @@ while (!windowShouldClose()) { W.MESH_SCALE[i], WHITE); } } - // Water — tessellate each segment into a grid of small flat tiles - // whose Y sits on a travelling wave. Colour blends deep-indigo trough - // → pale cyan crest on a smooth height term. A second high-frequency - // ripple crosses the surface. Flow is encoded as a wave phase that - // advances in +X with time, so crests visibly travel downstream. - // Tiles overlap their neighbours (×1.08) so the grid edges disappear - // into the blend. - // - // ≈ 0.35 m tiles × 6 segments ≈ 1800 cubes/frame. Free at our - // draw-call budget; no engine shader work required. - { - const tNow = getTime(); - const TILE = 0.35; - const baseA = 205; - const foamA = 200; - for (let i = 0; i < W.WATER_COUNT; i++) { - const cx = W.WATER_CX[i], cy = W.WATER_CY[i], cz = W.WATER_CZ[i]; - const sx = W.WATER_SX[i], sz = W.WATER_SZ[i]; - const amp = W.WATER_WAVE_AMP[i]; - const spd = W.WATER_WAVE_SPD[i]; - const nx = Math.max(1, Math.floor(sx / TILE)); - const nz = Math.max(1, Math.floor(sz / TILE)); - const stepX = sx / nx; - const stepZ = sz / nz; - // Downstream wave — long wavelength so adjacent tiles share close - // heights and the grid reads as a continuous wave, not a checker. - const kx = 0.45; - const kz = 0.75; - for (let ix = 0; ix < nx; ix++) { - for (let iz = 0; iz < nz; iz++) { - const x = cx - sx * 0.5 + stepX * (ix + 0.5); - const z = cz - sz * 0.5 + stepZ * (iz + 0.5); - const w1 = Math.sin(x * kx + tNow * spd * 1.6); - const w2 = Math.sin(z * kz + x * 0.18 + tNow * spd * 2.1); - const w3 = Math.sin(x * 2.1 + z * 1.7 + tNow * spd * 3.5) * 0.25; - const waveN = (w1 + w2 + w3 * 4) * 0.25; // -1..~1 - const h01 = (waveN + 1) * 0.5; // 0..1 - const dy = waveN * amp; - // Deep-water blue at troughs, pale cyan at crests. - const r = Math.floor(18 + h01 * 55); - const g = Math.floor(75 + h01 * 105); - const b = Math.floor(125 + h01 * 85); - drawCube(vec3(x, cy + dy, z), - stepX * 1.08, 0.03, stepZ * 1.08, - { r: r, g: g, b: b, a: baseA }); - // Foam: smooth falloff from h01=0.75 upward; no hard threshold. - if (h01 > 0.75) { - const fh = (h01 - 0.75) / 0.25; - drawCube(vec3(x, cy + dy + 0.025, z), - stepX * 0.85, 0.015, stepZ * 0.85, - { r: 230, g: 240, b: 250, - a: Math.floor(foamA * fh * fh) }); - } - } - } - } + // The house — one stone-textured mesh baked from every building box, with + // world-space vertices, so it draws at the origin with identity transform. + drawModel(mdlHouse, vec3(0, 0, 0), 1.0, WHITE); + if (VERIFY_CALIB) drawModel(mdlCalib, CALIB_AT, 1.0, WHITE); + // Scattered ground grass tufts + wildflowers (alpha-cutout, wind-swaying). + for (let i = 0; i < tuftCount; i++) { + const mdl = tuftFlower[i] === 1 ? mdlFlower : mdlGrassTuft; + drawModelRotated(mdl, vec3(tuftX[i], tuftY[i], tuftZ[i]), + tuftS[i], tuftR[i], WHITE); + } + // Perimeter treeline framing the arena. + for (let i = 0; i < rtCount; i++) { + drawModelRotated(mdlTreeRing, vec3(rtX[i], rtY[i], rtZ[i]), + rtS[i], rtR[i], WHITE); + } + // Water — one reflective material plane per river segment (see WATER_WGSL + // setup near the top). The planar-reflection probe renders the mirrored + // scene each frame; the shader blends it with Fresnel + animated wave + // normals + a sun glint. Far cheaper than the old ~1800 cubes/frame and + // actually reflects the world above the surface. + if (waterMat > 0) { + drawMeshWithMaterial(waterMat, waterMesh, + vec3(RIVER_CX, waterY, RIVER_CZ), 1.0, WHITE); } // Point lights from the world file — static scene lights. for (let i = 0; i < W.LIGHT_COUNT; i++) { @@ -931,14 +1301,23 @@ while (!windowShouldClose()) { // direction. The bsuit's only "attack" animation is a melee // swing — a ranged shooter shouldn't use it; keep the walk/idle // pose and fake recoil + muzzle flash on the weapon itself. - const modelYaw = camYaw + Math.PI / 2; - const fsin = Math.sin(modelYaw); - const fcos = -Math.cos(modelYaw); + // Face the camera's look direction (PUBG/aim-style), always. + // updateModelAnimation takes a SINGLE Y angle (radians) now — the old + // (sin,cos) overload was removed (the extra arg was silently dropped, so the + // body rotated by sin(camYaw) rad ≈ ±57° and "stayed looking left"). + // set_joint_matrices_scaled maps the model's +X rest-facing to + // (cosθ,-sinθ); the camera looks along (sin camYaw, -cos camYaw), so + // θ = π/2 - camYaw. (General: to face world dir (dx,dz), rot_y = atan2(-dz,dx).) + const rotY = Math.PI / 2 - camYaw; const panim = moving ? PLAYER_ANIM_WALK : PLAYER_ANIM_IDLE; - updateModelAnimation(animPlayer, panim, playerAnimT, PLAYER_SCALE, - pp.x, pp.y + PLAYER_MODEL_Y_OFFSET, pp.z, fsin, fcos); - drawModel(mdlPlayer, vec3(pp.x, pp.y + PLAYER_MODEL_Y_OFFSET, pp.z), - PLAYER_SCALE, WHITE); + // Third-person (PUBG-style): render the character body. + const FIRST_PERSON = false; + if (!FIRST_PERSON) { + updateModelAnimation(animPlayer, panim, playerAnimT, PLAYER_SCALE, + pp.x, pp.y + PLAYER_MODEL_Y_OFFSET, pp.z, rotY); + drawModel(mdlPlayer, vec3(pp.x, pp.y + PLAYER_MODEL_Y_OFFSET, pp.z), + PLAYER_SCALE, WHITE); + } // Held weapon — sketched from cubes since we don't yet have a // converted tpweapon GLB. Rifle is a long grey body + thin @@ -1009,12 +1388,13 @@ while (!windowShouldClose()) { const dxA = ppAim.x - enX[i]; const dzA = ppAim.z - enZ[i]; const distA = Math.sqrt(dxA * dxA + dzA * dzA); - const faceSin = distA > 0.001 ? dxA / distA : 0; - const faceCos = distA > 0.001 ? -dzA / distA : -1; + // Single Y angle (radians) — face the player. To face world dir (dx,dz): + // rot_y = atan2(-dz, dx) (see player facing note). atan2(0,0)→0 is fine. + const enRotY = Math.atan2(-dzA, dxA); const attacking = distA <= KIND_MELEE[k]; const animIdx = attacking ? ANIM_ATTACK_IDX[k] : ANIM_WALK_IDX[k]; updateModelAnimation(animAliens[k], animIdx, enPhase[i], KIND_SCALE[k], - enX[i], enY[i], enZ[i], faceSin, faceCos); + enX[i], enY[i], enZ[i], enRotY); const f = enFlashT[i] > 0 ? enFlashT[i] / DRETCH_HIT_FLASH : 0; const tint = f > 0 ? { r: 255, diff --git a/tools/build-props.ts b/tools/build-props.ts index 86329e8..2ad13f4 100644 --- a/tools/build-props.ts +++ b/tools/build-props.ts @@ -22,6 +22,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { dirname } from 'node:path'; +import { encodePng, leafTexture, barkTexture, grassBladeTexture, flowerTexture, + stoneTexture, woodTexture, metalTexture, floorTexture, heightToNormal, + roughnessMR } from './png'; const TEX_MAX = 512; const TEX_ROOT = 'vendor/unvanquished/pkg/tex-tech_src.dpkdir/textures/shared_tech_src'; @@ -40,6 +43,9 @@ interface Part { textureKey: string | null; // Resolved to texture index if non-null roughness: number; metallic: number; + alphaMode?: 'MASK'; // alpha-cutout (foliage cards) + alphaCutoff?: number; // threshold for MASK (default 0.5) + doubleSided?: boolean; // render both faces (foliage) } type Mesh = Part[]; @@ -192,7 +198,58 @@ const TEX_SPECS: Record = { floor: { key: 'floor', srcPath: TEX_ROOT + '/floortile2_d.png' }, }; +// Procedurally-generated textures (PNG bytes) — no external source needed. +// Leaf is RGBA with a real alpha channel for alpha-cutout foliage cards. +// Raw RGBA for the solid (non-cutout) materials, kept so we can both encode the +// albedo PNG and derive a tangent-space normal map from it (height = luminance). +const stoneRgba = stoneTexture(512); +const woodRgba = woodTexture(512); +const metalRgba = metalTexture(256); +const floorRgba = floorTexture(512); +const barkRgba = barkTexture(256); + +const PROC_TEX: Record = { + leaf: encodePng(256, 256, leafTexture(256)), + bark: encodePng(256, 256, barkRgba), + grass_blade: encodePng(256, 256, grassBladeTexture(256)), + flower: encodePng(256, 256, flowerTexture(256)), + // Stone/wood/metal/floor: procedural fallbacks so the building + props are + // properly textured even when the Unvanquished tex-tech vendor source isn't + // present (it isn't on this machine — see TEX_ROOT). Without these the wall + // fell back to a flat solid grey, reading as a plain white box. + stone: encodePng(512, 512, stoneRgba), + wood: encodePng(512, 512, woodRgba), + metal: encodePng(256, 256, metalRgba), + floor: encodePng(512, 512, floorRgba), + // Derived normal maps — give the masonry/planks per-texel relief so they + // catch the directional sun instead of shading flat. Referenced as a + // material's normalTexture (NORMAL_FOR), which makes the model loader treat + // them as linear, mip-variance-baked normal maps. + stone_n: encodePng(512, 512, heightToNormal(512, 512, stoneRgba, 3.0)), + wood_n: encodePng(512, 512, heightToNormal(512, 512, woodRgba, 2.2)), + metal_n: encodePng(256, 256, heightToNormal(256, 256, metalRgba, 1.0)), + floor_n: encodePng(512, 512, heightToNormal(512, 512, floorRgba, 1.8)), + bark_n: encodePng(256, 256, heightToNormal(256, 256, barkRgba, 2.6)), + // Per-texel roughness (metallic-roughness maps) — recessed mortar/grooves + // read matte, faces a touch glossier; all dielectric (metallic 0). + stone_mr: encodePng(512, 512, roughnessMR(512, 512, stoneRgba, 0.72, 0.97)), + wood_mr: encodePng(512, 512, roughnessMR(512, 512, woodRgba, 0.62, 0.90)), + floor_mr: encodePng(512, 512, roughnessMR(512, 512, floorRgba, 0.60, 0.88)), + bark_mr: encodePng(256, 256, roughnessMR(256, 256, barkRgba, 0.78, 0.97)), +}; + +// Albedo texture key → its normal-map key (solid materials only; the alpha +// cutout cards leaf/grass_blade/flower intentionally get none). +const NORMAL_FOR: Record = { + stone: 'stone_n', wood: 'wood_n', metal: 'metal_n', floor: 'floor_n', bark: 'bark_n', +}; +// Albedo texture key → its metallic-roughness map key. +const MR_FOR: Record = { + stone: 'stone_mr', wood: 'wood_mr', floor: 'floor_mr', bark: 'bark_mr', +}; + function resolveTexture(key: string): Uint8Array { + if (PROC_TEX[key]) return PROC_TEX[key]; const spec = TEX_SPECS[key]; if (!spec) throw new Error('unknown texture key: ' + key); if (!existsSync(spec.srcPath)) { @@ -221,15 +278,178 @@ const FLOOR_WOOD: [number, number, number] = [0.80, 0.65, 0.48]; const FABRIC_RED: [number, number, number] = [0.68, 0.20, 0.18]; const FABRIC_WHITE: [number, number, number] = [0.92, 0.90, 0.85]; +// Append a double-sided alpha-cutout leaf card (a textured quad) to shared +// vertex/index arrays. `yaw` rotates the card's facing around Y; `tilt` lifts +// the facing toward the sky. Double-sided = front quad (normal n) + back quad +// (normal -n, reversed winding) so the leaves read from any viewing side. +function addLeafCard(verts: number[], indices: number[], + cx: number, cy: number, cz: number, + w: number, h: number, yaw: number, tilt: number): void { + const ct = Math.cos(tilt), st = Math.sin(tilt); + let nx = Math.sin(yaw) * ct, ny = st, nz = Math.cos(yaw) * ct; + // right = normalize(cross(worldUp, n)); up = normalize(cross(n, right)). + let rx = nz, ry = 0, rz = -nx; + const rl = Math.hypot(rx, ry, rz) || 1; rx /= rl; ry /= rl; rz /= rl; + let ux = ny * rz - nz * ry, uy = nz * rx - nx * rz, uz = nx * ry - ny * rx; + const ul = Math.hypot(ux, uy, uz) || 1; ux /= ul; uy /= ul; uz /= ul; + const hw = w * 0.5, hh = h * 0.5; + const corner = (sx: number, sy: number, u: number, v: number) => [ + cx + rx * hw * sx + ux * hh * sy, + cy + ry * hw * sx + uy * hh * sy, + cz + rz * hw * sx + uz * hh * sy, + u, v, + ]; + const cs = [corner(-1, -1, 0, 0), corner(1, -1, 1, 0), corner(1, 1, 1, 1), corner(-1, 1, 0, 1)]; + const b0 = verts.length / 8; + for (const c of cs) verts.push(c[0], c[1], c[2], nx, ny, nz, c[3], c[4]); + indices.push(b0, b0 + 1, b0 + 2, b0, b0 + 2, b0 + 3); + const b1 = verts.length / 8; + for (const c of cs) verts.push(c[0], c[1], c[2], -nx, -ny, -nz, c[3], c[4]); + indices.push(b1, b1 + 2, b1 + 1, b1, b1 + 3, b1 + 2); +} + +// PUBG-style tree: a bark-textured trunk + a canopy built from a cloud of +// alpha-cutout leaf cards (instead of solid green cones). The leaf texture has +// a real alpha channel; the GLB material is alphaMode=MASK so the engine's +// fragment shader discards the gaps → see-through, leafy foliage that casts +// and receives shadows through the normal drawModel path. +// Organic tapered trunk: a tube that narrows base→top, leans slightly, flares +// at the roots, and has a gently non-circular (wobbled) cross-section — so it +// reads as a tree, not a perfectly round pole. Per-quad winding mirrors +// pushCylinder (known-good outward faces). UVs tile the bark at TILE_METRES. +function pushTrunk(m: Mesh, baseR: number, topR: number, height: number, + sides: number, lean: number, segs: number, + color: [number, number, number], textureKey: string): void { + const verts: number[] = [], indices: number[] = []; + const prof = (t: number): number => { + let r = baseR + (topR - baseR) * t; + if (t < 0.2) r += baseR * 0.5 * (1 - t / 0.2); // root flare + return r; + }; + const lx = (t: number): number => Math.sin(t * 1.4) * lean; // gentle lean (x) + const lz = (t: number): number => Math.sin(t * 1.4 + 1.0) * lean * 0.5; // + slight z bend + const wob = (a: number): number => 1 + Math.sin(a * 2.0) * 0.07 + Math.sin(a * 5.0 + 1.3) * 0.035; + for (let s = 0; s < segs; s++) { + const t0 = s / segs, t1 = (s + 1) / segs; + const yb = t0 * height, yt = t1 * height; + const rb = prof(t0), rt = prof(t1); + const oxb = lx(t0), ozb = lz(t0), oxt = lx(t1), ozt = lz(t1); + const vb = yb / TILE_METRES, vt = yt / TILE_METRES; + for (let j = 0; j < sides; j++) { + const a0 = (j / sides) * Math.PI * 2, a1 = ((j + 1) / sides) * Math.PI * 2; + const am = (a0 + a1) * 0.5; + const c0 = Math.cos(a0), s0 = Math.sin(a0), c1 = Math.cos(a1), s1 = Math.sin(a1); + const nx = Math.cos(am), nz = Math.sin(am); + const w0 = wob(a0), w1 = wob(a1); + const u0 = (j / sides) * (2 * Math.PI * rb) / TILE_METRES; + const u1 = ((j + 1) / sides) * (2 * Math.PI * rb) / TILE_METRES; + const b = verts.length / 8; + verts.push( + oxb + rb * w0 * c0, yb, ozb + rb * w0 * s0, nx, 0.12, nz, u0, vb, + oxb + rb * w1 * c1, yb, ozb + rb * w1 * s1, nx, 0.12, nz, u1, vb, + oxt + rt * w1 * c1, yt, ozt + rt * w1 * s1, nx, 0.12, nz, u1, vt, + oxt + rt * w0 * c0, yt, ozt + rt * w0 * s0, nx, 0.12, nz, u0, vt, + ); + indices.push(b, b + 1, b + 2, b, b + 2, b + 3); + } + } + m.push({ vertices: verts, indices, color, textureKey, roughness: 0.95, metallic: 0.0 }); +} + function makeTree(): Mesh { const m: Mesh = []; - pushCylinder(m, 0, 1.2, 0, 0.22, 1.2, 8, BARK, 0.95, 0.0); - pushCone(m, 0, 1.2, 0, 1.1, 1.0, 10, LEAF); - pushCone(m, 0, 2.0, 0, 0.95, 1.0, 10, LEAF); - pushCone(m, 0, 2.8, 0, 0.75, 1.0, 10, LEAF); + // Deterministic pseudo-random so the GLB is reproducible. + let seed = 90187; + const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }; + + // Tapered, leaning, root-flared trunk (12 sides for a smooth-but-organic + // profile) + a couple of low branch stubs so it isn't a bare pole. + pushTrunk(m, 0.28, 0.10, 3.0, 12, 0.22, 7, [1, 1, 1], 'bark'); + pushCylinder(m, 0.55, 2.05, 0.2, 0.07, 0.5, 6, [1, 1, 1], 0.95, 0.0, 'bark'); + pushCylinder(m, -0.5, 2.35, -0.3, 0.06, 0.45, 6, [1, 1, 1], 0.95, 0.0, 'bark'); + + const fv: number[] = [], fi: number[] = []; + // Canopy = several overlapping leaf-card CLUMPS (foliage masses) rather than + // symmetric horizontal rings — gives a rounder, ragged, non-boxy silhouette. + // Each clump scatters cards through a squashed sphere with size + tilt jitter. + const clumps = [ + { cx: 0.0, cy: 3.5, cz: 0.0, rad: 1.55, n: 30 }, + { cx: 0.95, cy: 3.05, cz: 0.45, rad: 1.15, n: 18 }, + { cx: -0.85, cy: 3.15, cz: -0.55, rad: 1.15, n: 18 }, + { cx: 0.25, cy: 4.2, cz: -0.35, rad: 1.0, n: 15 }, + { cx: -0.35, cy: 2.7, cz: 0.75, rad: 0.95, n: 13 }, + { cx: 0.6, cy: 3.7, cz: -0.7, rad: 0.9, n: 12 }, + ]; + for (const cl of clumps) { + for (let i = 0; i < cl.n; i++) { + // Uniform-ish point in a squashed sphere (slightly flattened vertically). + const uu = rnd() * 2 - 1, th = rnd() * Math.PI * 2; + const rr = cl.rad * (0.35 + rnd() * 0.65); + const sq = Math.sqrt(Math.max(0, 1 - uu * uu)); + const px = cl.cx + rr * sq * Math.cos(th); + const py = cl.cy + rr * uu * 0.82; + const pz = cl.cz + rr * sq * Math.sin(th); + const yaw = Math.atan2(px - cl.cx, pz - cl.cz) + (rnd() - 0.5) * 0.7; + const size = 1.35 + rnd() * 1.15; + addLeafCard(fv, fi, px, py, pz, size, size, yaw, rnd() * 0.95); + } + } + // A few ragged outliers poking past the clumps so the edge isn't a clean ball. + for (let i = 0; i < 10; i++) { + const yaw = rnd() * Math.PI * 2; + const r = 1.7 + rnd() * 0.7; + addLeafCard(fv, fi, Math.sin(yaw) * r, 3.3 + (rnd() - 0.3) * 2.0, + Math.cos(yaw) * r, 1.0 + rnd() * 0.9, 1.0 + rnd() * 0.9, + yaw, rnd() * 0.8); + } + m.push({ + vertices: fv, indices: fi, color: [1, 1, 1], textureKey: 'leaf', + roughness: 0.9, metallic: 0.0, alphaMode: 'MASK', alphaCutoff: 0.5, + }); return m; } +// Scattered ground grass tuft: a few crossed alpha-cutout blade cards rooted at +// the ground (card bottom at y=0). alphaMode MASK → the engine discards the gaps +// and, because the material is alpha-cutout, the foliage wind-sway + backlit +// translucency in the scene shader both apply (it sways in the breeze). +function makeGrassTuft(): Mesh { + const fv: number[] = [], fi: number[] = []; + let seed = 24681; + const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }; + const blades = 5; + for (let i = 0; i < blades; i++) { + const yaw = (i / blades) * Math.PI + (rnd() - 0.5) * 0.7; + const h = 0.42 + rnd() * 0.34; + const w = 0.55 + rnd() * 0.35; + const ox = (rnd() - 0.5) * 0.28, oz = (rnd() - 0.5) * 0.28; + addLeafCard(fv, fi, ox, h * 0.5, oz, w, h, yaw, (rnd() - 0.5) * 0.15); + } + return [{ + vertices: fv, indices: fi, color: [1, 1, 1], textureKey: 'grass_blade', + roughness: 0.95, metallic: 0.0, alphaMode: 'MASK', alphaCutoff: 0.4, + }]; +} + +// Wildflower clump: a couple of crossed alpha-cutout flower cards, rooted at +// the ground. Same foliage path as the grass tufts (sways in the wind). +function makeFlowerTuft(): Mesh { + const fv: number[] = [], fi: number[] = []; + let seed = 51237; + const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }; + for (let i = 0; i < 3; i++) { + const yaw = (i / 3) * Math.PI + (rnd() - 0.5) * 0.6; + const h = 0.34 + rnd() * 0.22; + const w = 0.40 + rnd() * 0.20; + const ox = (rnd() - 0.5) * 0.2, oz = (rnd() - 0.5) * 0.2; + addLeafCard(fv, fi, ox, h * 0.5, oz, w, h, yaw, 0.0); + } + return [{ + vertices: fv, indices: fi, color: [1, 1, 1], textureKey: 'flower', + roughness: 0.95, metallic: 0.0, alphaMode: 'MASK', alphaCutoff: 0.4, + }]; +} + function makeCrate(): Mesh { const m: Mesh = []; const s = 0.5; @@ -291,6 +511,46 @@ function makeBuildingFloor(): Mesh { return m; } +// Bake the whole house — every "building"-tagged box collider in the world — +// into ONE stone-textured mesh whose vertices are already in world space, so +// the runtime draws it with a single drawModel at the origin (scale 1) instead +// of dozens of flat-coloured drawCube placeholders. The boxes stay as invisible +// physics colliders (created separately in main.ts); this only replaces their +// look. pushBox tiles the stone at TILE_METRES (2 m), so block size is uniform +// across walls of any dimension. +function makeHouse(worldPath: string): Mesh { + const world = JSON.parse(readFileSync(worldPath, 'utf8')); + const m: Mesh = []; + let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity, maxY = -Infinity; + for (const e of world.entities) { + const ud = e.userData || {}; + const tags: string[] = e.tags || []; + if (ud.kind !== 'static_mesh' || ud.collider !== 'box') continue; + if (tags.indexOf('building') < 0) continue; + const p = e.transform.position; + const he = String(ud.halfExtents).split(',').map((t: string) => parseFloat(t.trim())); + pushBox(m, p[0], p[1], p[2], he[0], he[1], he[2], STONE, 0.92, 0.0, 'stone'); + // Track footprint from the tall perimeter walls only (he.y > 1) so the + // roof spans the building, not the low entrance steps that stick out. + if (he[1] > 1.0) { + minX = Math.min(minX, p[0] - he[0]); maxX = Math.max(maxX, p[0] + he[0]); + minZ = Math.min(minZ, p[2] - he[2]); maxZ = Math.max(maxZ, p[2] + he[2]); + maxY = Math.max(maxY, p[1] + he[1]); + } + } + // Cap the open enclosure with a flat wooden-plank roof + a small overhang and + // a stone fascia band just under the eaves, so it reads as a finished house. + if (maxY > -Infinity) { + const cx = (minX + maxX) / 2, cz = (minZ + maxZ) / 2; + const hx = (maxX - minX) / 2 + 0.35, hz = (maxZ - minZ) / 2 + 0.35; + pushBox(m, cx, maxY + 0.02, cz, hx - 0.34, 0.10, hz - 0.34, + [0.42, 0.46, 0.50], 0.9, 0.0, 'stone'); // stone fascia just below eaves + pushBox(m, cx, maxY + 0.18, cz, hx, 0.12, hz, + [0.34, 0.24, 0.17], 0.85, 0.0, 'wood'); // wooden roof deck + overhang + } + return m; +} + // ----------------------------------------------------------------------------- // GLB assembly // ----------------------------------------------------------------------------- @@ -304,6 +564,15 @@ function writeGlb(outPath: string, mesh: Mesh): void { for (const p of mesh) { if (p.textureKey && texKeys.indexOf(p.textureKey) < 0) texKeys.push(p.textureKey); } + // Pull in the normal-map + metallic-roughness variants of every base texture + // that has them, so they get embedded + indexed alongside the albedo + // (referenced via normalTexture / metallicRoughnessTexture). + for (const k of [...texKeys]) { + const nk = NORMAL_FOR[k]; + if (nk && texKeys.indexOf(nk) < 0) texKeys.push(nk); + const mk = MR_FOR[k]; + if (mk && texKeys.indexOf(mk) < 0) texKeys.push(mk); + } const texBytes: Uint8Array[] = texKeys.map(k => resolveTexture(k)); interface Slot { off: number; len: number } @@ -403,6 +672,7 @@ function writeGlb(outPath: string, mesh: Mesh): void { metallicFactor: p.metallic, roughnessFactor: p.roughness, }; + let normalTexIdx = -1; if (p.textureKey) { const ti = texKeys.indexOf(p.textureKey); if (ti >= 0 && texBytes[ti].length > 0) { @@ -411,8 +681,29 @@ function writeGlb(outPath: string, mesh: Mesh): void { // material isn't overly dark. pbr.baseColorFactor = [1.0, 1.0, 1.0, 1.0]; } + const nk = NORMAL_FOR[p.textureKey]; + if (nk) { + const ni = texKeys.indexOf(nk); + if (ni >= 0 && texBytes[ni].length > 0) normalTexIdx = ni; + } + const mk = MR_FOR[p.textureKey]; + if (mk) { + const mi = texKeys.indexOf(mk); + if (mi >= 0 && texBytes[mi].length > 0) { + pbr.metallicRoughnessTexture = { index: mi }; + pbr.metallicFactor = 0.0; + pbr.roughnessFactor = 1.0; // texture supplies roughness; factor multiplies + } + } } - materials.push({ name: 'mat_' + i, pbrMetallicRoughness: pbr }); + const matObj: any = { name: 'mat_' + i, pbrMetallicRoughness: pbr }; + if (normalTexIdx >= 0) matObj.normalTexture = { index: normalTexIdx, scale: 1.0 }; + if (p.alphaMode === 'MASK') { + matObj.alphaMode = 'MASK'; + matObj.alphaCutoff = p.alphaCutoff ?? 0.5; + } + if (p.doubleSided) matObj.doubleSided = true; + materials.push(matObj); primitives.push({ attributes: { POSITION: aPos, NORMAL: aNrm, TEXCOORD_0: aUv }, @@ -474,7 +765,58 @@ function writeGlb(outPath: string, mesh: Mesh): void { console.log('wrote', outPath, '(' + out.length, 'bytes,', mesh.length, 'parts,', texBytes.filter(b => b.length > 0).length, 'textures)'); } +// The tree is fully procedural (leaf + bark textures generated here), so it +// always regenerates. The remaining props bake Unvanquished tex-tech sources +// (vendor/, gitignored) via macOS `sips`; only regenerate those when the source +// tree is present, so a tree-only rebuild on a fresh/Windows checkout doesn't +// strip the committed textures from the other props. +// All props build unconditionally now: stone/wood/metal/floor have procedural +// PROC_TEX fallbacks (see resolveTexture), so the building + furniture are +// fully textured even without the Unvanquished tex-tech vendor source. (They +// previously fell back to a flat solid grey — the "white box" building.) +// UV sphere with outward normals (= normalized position), flat albedo + given +// roughness/metallic. For the PBR calibration rig. +function pushSphere(m: Mesh, cx: number, cy: number, cz: number, radius: number, + color: [number, number, number], roughness: number, metallic: number): void { + const verts: number[] = [], indices: number[] = []; + const STACKS = 24, SLICES = 32; + for (let i = 0; i <= STACKS; i++) { + const v = i / STACKS, phi = v * Math.PI; + const y = Math.cos(phi), r = Math.sin(phi); + for (let j = 0; j <= SLICES; j++) { + const u = j / SLICES, th = u * 2 * Math.PI; + const x = r * Math.cos(th), z = r * Math.sin(th); + verts.push(cx + radius * x, cy + radius * y, cz + radius * z, x, y, z, u, v); + } + } + const row = SLICES + 1; + for (let i = 0; i < STACKS; i++) { + for (let j = 0; j < SLICES; j++) { + const a = i * row + j, b = a + 1, c = a + row, d = c + 1; + indices.push(a, c, b, b, c, d); + } + } + m.push({ vertices: verts, indices, color, textureKey: null, roughness, metallic }); +} + +// PBR calibration rig — drawn through the scene shader (real sun + IBL), so it +// objectively validates the linear/exposure pipeline and the BRDF. Row 1: grey +// albedos 0.18/0.5/0.9 (mid-grey should land ~mid after AgX). Row 2: roughness +// sweep 0→1. Row 3: metallic sweep 0→1. Toggle in-game via VERIFY_CALIB. +function makeCalibRig(): Mesh { + const m: Mesh = []; + const R = 0.6, gap = 1.6; + const greys = [0.18, 0.5, 0.9]; + for (let i = 0; i < 3; i++) pushSphere(m, i * gap, R, 0, R, [greys[i], greys[i], greys[i]], 0.5, 0.0); + for (let i = 0; i < 5; i++) pushSphere(m, i * gap, R, -gap, R, [0.5, 0.5, 0.5], Math.max(0.045, i / 4), 0.0); + for (let i = 0; i < 5; i++) pushSphere(m, i * gap, R, -2 * gap, R, [0.95, 0.78, 0.5], 0.2, i / 4); + return m; +} + writeGlb('assets/models/prop_tree.glb', makeTree()); +writeGlb('assets/models/calib_rig.glb', makeCalibRig()); +writeGlb('assets/models/prop_grasstuft.glb', makeGrassTuft()); +writeGlb('assets/models/prop_flower.glb', makeFlowerTuft()); writeGlb('assets/models/prop_crate.glb', makeCrate()); writeGlb('assets/models/prop_barrel.glb', makeBarrel()); writeGlb('assets/models/prop_table.glb', makeTable()); @@ -482,3 +824,4 @@ writeGlb('assets/models/prop_chair.glb', makeChair()); writeGlb('assets/models/prop_bed.glb', makeBed()); writeGlb('assets/models/building_wall.glb', makeBuildingWall()); writeGlb('assets/models/building_floor.glb', makeBuildingFloor()); +writeGlb('assets/models/house.glb', makeHouse('assets/worlds/arena_02.world.json')); diff --git a/tools/build-terrain.ts b/tools/build-terrain.ts index d3166e6..427b82b 100644 --- a/tools/build-terrain.ts +++ b/tools/build-terrain.ts @@ -11,7 +11,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; -import { encodePng, grassTexture } from './png'; +import { encodePng, grassTexture, heightToNormal } from './png'; const OUT_GLB = 'assets/models/terrain_hills.glb'; const OUT_TS = 'src/generated/terrain.ts'; @@ -56,7 +56,28 @@ function heightAt(x: number, z: number): number { // Low-frequency waviness everywhere so the flat plate doesn't look dead. h += 0.25 * Math.sin(x * 0.08) * Math.cos(z * 0.10); h += 0.18 * Math.sin(x * 0.17 + z * 0.11); - return h * plazaBlend; + let y = h * plazaBlend; + + // River channel — carve the terrain BELOW the water plane (water sits at + // y≈0.05) along the river path (segments run z≈11–13 across x≈-37..39). + // Without this the riverbed terrain — up to ~2.3 m under some segments — + // pokes through the flat water surface. The bed is forced to BED at the + // centre and smoothly ramps back to the natural terrain over BANK metres, + // so the river reads as a proper carved channel with grassy banks. Drives + // both the visual GLB and the Jolt heightfield (one source of truth). + const RIVER_Z = 12.0, BED = -0.55, HALF = 2.6, BANK = 2.4; + if (x > -38 && x < 40) { + const dzr = Math.abs(z - RIVER_Z); + if (dzr < HALF + BANK) { + let carve = 1.0; + if (dzr > HALF) { + const tt = 1 - (dzr - HALF) / BANK; // 1 at bed edge → 0 at bank top + carve = tt * tt * (3 - 2 * tt); // smoothstep + } + y = y * (1 - carve) + BED * carve; + } + } + return y; } // Build the mesh. @@ -68,8 +89,10 @@ const normals = new Float32Array(vertCount * 3); const uvs = new Float32Array(vertCount * 2); const indices = new Uint32Array(triCount * 3); -// UV tile rate — 1 texture repeat per UV_TILE world metres. -const UV_TILE = 4; +// UV tile rate — 1 texture repeat per UV_TILE world metres. Larger = fewer +// visible repeats across the 80 m field (4 m gave ~20 obvious repeats); the +// seamless grass texture + higher resolution keep per-metre detail crisp. +const UV_TILE = 8; // First pass: positions only. const heights = new Float32Array(vertCount); @@ -134,8 +157,13 @@ for (let v = 1; v < vertCount; v++) { function align4(n: number): number { return (n + 3) & ~3; } // Procedural grass texture — tiled across the whole terrain via UVs above. -const TEX_SIZE = 256; -const grassPng = encodePng(TEX_SIZE, TEX_SIZE, grassTexture(TEX_SIZE)); +// Plus a derived normal map so the grass catches the directional sun (per-texel +// surface relief) instead of shading flat. +const TEX_SIZE = 512; +const grassRgba = grassTexture(TEX_SIZE); +const grassPng = encodePng(TEX_SIZE, TEX_SIZE, grassRgba); +const grassNrmPng = encodePng(TEX_SIZE, TEX_SIZE, + heightToNormal(TEX_SIZE, TEX_SIZE, grassRgba, 2.5)); const idxOff = 0; const idxLen = indices.byteLength; @@ -147,7 +175,9 @@ const uvOff = align4(nrmOff + nrmLen); const uvLen = uvs.byteLength; const imgOff = align4(uvOff + uvLen); const imgLen = grassPng.length; -const binLen = align4(imgOff + imgLen); +const nrmImgOff = align4(imgOff + imgLen); +const nrmImgLen = grassNrmPng.length; +const binLen = align4(nrmImgOff + nrmImgLen); const bin = new Uint8Array(binLen); bin.set(new Uint8Array(indices.buffer), idxOff); @@ -155,6 +185,7 @@ bin.set(new Uint8Array(positions.buffer), posOff); bin.set(new Uint8Array(normals.buffer), nrmOff); bin.set(new Uint8Array(uvs.buffer), uvOff); bin.set(grassPng, imgOff); +bin.set(grassNrmPng, nrmImgOff); const gltf = { asset: { version: '2.0', generator: 'shooter-build-terrain' }, @@ -175,9 +206,13 @@ const gltf = { metallicFactor: 0.0, roughnessFactor: 0.95, }, + normalTexture: { index: 1, scale: 1.0 }, }], - textures: [{ source: 0, sampler: 0 }], - images: [{ bufferView: 4, mimeType: 'image/png' }], + textures: [{ source: 0, sampler: 0 }, { source: 1, sampler: 0 }], + images: [ + { bufferView: 4, mimeType: 'image/png' }, + { bufferView: 5, mimeType: 'image/png' }, + ], samplers: [{ magFilter: 9729, minFilter: 9987, wrapS: 10497, wrapT: 10497 }], // REPEAT buffers: [{ byteLength: binLen }], bufferViews: [ @@ -186,6 +221,7 @@ const gltf = { { buffer: 0, byteOffset: nrmOff, byteLength: nrmLen, target: 34962 }, { buffer: 0, byteOffset: uvOff, byteLength: uvLen, target: 34962 }, { buffer: 0, byteOffset: imgOff, byteLength: imgLen }, + { buffer: 0, byteOffset: nrmImgOff, byteLength: nrmImgLen }, ], accessors: [ { bufferView: 0, componentType: 5125, count: indices.length, type: 'SCALAR' }, diff --git a/tools/geisterhand-smoketest.ps1 b/tools/geisterhand-smoketest.ps1 new file mode 100644 index 0000000..ca0d6d0 --- /dev/null +++ b/tools/geisterhand-smoketest.ps1 @@ -0,0 +1,137 @@ +<# + geisterhand-smoketest.ps1 — black-box auto-test for Bloom Shooter on Windows. + + Launches the built ./main.exe, drives it through the geisterhand HTTP + automation server (screenshots + synthetic input), and verifies the game + actually renders and responds: frames are non-black, the picture changes + over time (enemies spawn / animate), and discrete inputs (weapon switch, + fire, quit) are accepted. + + Screenshots are written to tools/.testout/ for visual inspection, and a + PASS/FAIL summary is printed + returned as the exit code (0 = pass). + + Usage: + pwsh tools/geisterhand-smoketest.ps1 + pwsh tools/geisterhand-smoketest.ps1 -Exe .\main.exe -GhExe ..\..\geisterhandwindows\publish\geisterhand.exe +#> +[CmdletBinding()] +param( + [string]$Exe = '', + [string]$GhExe = '', + [int]$Port = 7676, + [int]$Frames = 6, # screenshots to capture + [double]$Interval = 1.5 # seconds between captures +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName Microsoft.VisualBasic + +# Robust script dir: $PSScriptRoot can be empty in some param-default contexts. +$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } + elseif ($PSCommandPath) { Split-Path -Parent $PSCommandPath } + else { 'C:\Users\Ralph\projects\bloom\shooter\tools' } +if (-not $Exe) { $Exe = Join-Path $ScriptDir '..\main.exe' } +if (-not $GhExe) { $GhExe = Join-Path $ScriptDir '..\..\..\geisterhandwindows\publish\geisterhand.exe' } + +$ProjectRoot = Resolve-Path (Join-Path $ScriptDir '..') +$OutDir = Join-Path $ScriptDir '.testout' +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +Get-ChildItem $OutDir -Filter 'shot_*.png' -ErrorAction SilentlyContinue | Remove-Item -Force + +$Exe = (Resolve-Path $Exe).Path +$GhExe = (Resolve-Path $GhExe).Path +$base = "http://127.0.0.1:$Port" + +function Save-Shot([string]$name) { + # The D3D12 game window only presents capturable frames while foreground — + # otherwise geisterhand captures a blank/white surface. Activate first. + try { [Microsoft.VisualBasic.Interaction]::AppActivate($script:GamePid) | Out-Null } catch {} + Start-Sleep -Milliseconds 400 + # geisterhand returns base64 PNG scoped to our game pid. + $uri = "$base/screenshot?pid=$($script:GamePid)&format=png" + try { + $resp = Invoke-RestMethod -Uri $uri -Method Get -TimeoutSec 15 + } catch { + Write-Warning "screenshot failed: $_"; return $null + } + $bytes = [Convert]::FromBase64String($resp.image) + $path = Join-Path $OutDir $name + [IO.File]::WriteAllBytes($path, $bytes) + # Compute a crude brightness + hash so we can detect black / static frames. + $bright = 0.0; $n = [Math]::Min($bytes.Length, 50000) + for ($i = 0; $i -lt $n; $i++) { $bright += $bytes[$i] } + $bright = $bright / $n + [pscustomobject]@{ path=$path; width=$resp.width; height=$resp.height; size=$bytes.Length; bright=[Math]::Round($bright,2) } +} + +function Send-Key([string]$key) { + try { Invoke-RestMethod -Uri "$base/key" -Method Post -ContentType 'application/json' ` + -Body (@{ key=$key; pid=$script:GamePid } | ConvertTo-Json) -TimeoutSec 10 | Out-Null } catch { Write-Warning "key $key failed: $_" } +} + +function Send-Click([int]$x,[int]$y) { + try { Invoke-RestMethod -Uri "$base/click" -Method Post -ContentType 'application/json' ` + -Body (@{ x=$x; y=$y; button='left'; pid=$script:GamePid } | ConvertTo-Json) -TimeoutSec 10 | Out-Null } catch { Write-Warning "click failed: $_" } +} + +Write-Host "== Bloom Shooter geisterhand smoke test ==" -ForegroundColor Cyan +Write-Host "exe: $Exe" +Write-Host "geisterhand: $GhExe" + +# 1. Start the global geisterhand server. +$ghProc = Start-Process -FilePath $GhExe -ArgumentList @('server','--port',"$Port") -PassThru -WindowStyle Hidden +Start-Sleep -Seconds 2 + +# 2. Launch the game (its own working dir = project root so it finds assets/). +$game = Start-Process -FilePath $Exe -WorkingDirectory $ProjectRoot -PassThru +$script:GamePid = $game.Id +Write-Host "game pid: $($script:GamePid)" +# The deferred pipeline (shadow maps, TAA accumulation, exposure) needs several +# seconds to converge before the first capture reads as a real frame. +Start-Sleep -Seconds 12 + +$results = @() +$passed = $true +$reasons = @() + +# 3. Capture a baseline frame. +$shot0 = Save-Shot 'shot_00_boot.png' +if ($null -eq $shot0) { $passed=$false; $reasons += 'no screenshot at boot' } +else { + $results += $shot0 + Write-Host ("boot frame: {0}x{1} bright={2}" -f $shot0.width,$shot0.height,$shot0.bright) + if ($shot0.bright -lt 6) { $passed=$false; $reasons += "boot frame too dark (bright=$($shot0.bright))" } +} + +# 4. Drive some discrete inputs while waves spawn, capturing frames. +for ($f=1; $f -lt $Frames; $f++) { + Start-Sleep -Seconds $Interval + if ($f -eq 2) { Send-Key 'two' } # switch to blaster + if ($f -eq 3) { Send-Click ([int]512) ([int]320) } # fire at screen center + if ($f -eq 4) { Send-Key 'one' } # back to rifle + $shot = Save-Shot ("shot_{0:d2}.png" -f $f) + if ($shot) { $results += $shot; Write-Host ("frame {0}: bright={1} size={2}" -f $f,$shot.bright,$shot.size) } +} + +# 5. Liveness: the process must still be running, and frames must vary. +$alive = -not $game.HasExited +if (-not $alive) { $passed=$false; $reasons += 'game process exited unexpectedly' } +$distinctBright = ($results | Select-Object -ExpandProperty bright | Sort-Object -Unique).Count +if ($results.Count -ge 3 -and $distinctBright -lt 2) { $passed=$false; $reasons += 'frames never changed (static render)' } + +# 6. Quit the game with ESC, give it a moment, force-kill if needed. +Send-Key 'escape' +Start-Sleep -Seconds 2 +if (-not $game.HasExited) { try { $game.Kill() } catch {} } +if ($ghProc -and -not $ghProc.HasExited) { try { $ghProc.Kill() } catch {} } + +Write-Host "" +Write-Host "frames captured: $($results.Count) -> $OutDir" +if ($passed) { + Write-Host "RESULT: PASS" -ForegroundColor Green + exit 0 +} else { + Write-Host "RESULT: FAIL" -ForegroundColor Red + $reasons | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } + exit 1 +} diff --git a/tools/png.ts b/tools/png.ts index 516029c..2558697 100644 --- a/tools/png.ts +++ b/tools/png.ts @@ -108,6 +108,34 @@ function fbm(x: number, y: number, octaves: number): number { return s / norm; } +// Periodic (seamless-tiling) value noise. Lattice coords wrap modulo the +// supplied period, so a texture sampled over x,y ∈ [0, period) tiles with no +// visible seam. Returns [0,1] like noise2. +function wrapInt(a: number, p: number): number { return ((a % p) + p) % p; } +function noise2p(x: number, y: number, px: number, py: number): number { + const xi = Math.floor(x), yi = Math.floor(y); + const xf = x - xi, yf = y - yi; + const a = hash2(wrapInt(xi, px), wrapInt(yi, py)); + const b = hash2(wrapInt(xi + 1, px), wrapInt(yi, py)); + const c = hash2(wrapInt(xi, px), wrapInt(yi + 1, py)); + const d = hash2(wrapInt(xi + 1, px), wrapInt(yi + 1, py)); + const u = fade(xf), v = fade(yf); + return a * (1 - u) * (1 - v) + b * u * (1 - v) + c * (1 - u) * v + d * u * v; +} +// Seamless fbm over a [0, period) domain. `base` is the lattice cells across +// the tile at octave 0; each octave doubles frequency AND period so the wrap +// stays exact. +function fbmp(u: number, v: number, octaves: number, base: number): number { + let s = 0, amp = 0.5, freq = 1, norm = 0; + for (let i = 0; i < octaves; i++) { + const p = base * freq; + s += amp * noise2p(u * p, v * p, p, p); + norm += amp; + amp *= 0.5; freq *= 2; + } + return s / norm; +} + // Build an RGBA buffer procedurally via a (x, y) -> [r, g, b, a] callback. export function makeTexture(width: number, height: number, fn: (x: number, y: number) => [number, number, number, number]): Uint8Array { @@ -125,28 +153,314 @@ export function makeTexture(width: number, height: number, return out; } +// Derive a tangent-space normal map from an RGBA albedo, treating its +// luminance as a height field (Sobel gradient). Wraps at the edges so the +// result tiles as seamlessly as the source. `strength` scales the bump. +// Encoded to match the engine's decode (`v * 2/255 - 1` → [-1,1]): channels +// store (n*0.5+0.5)*255. Blue ≈ up (flat) so an unbumped texel reads (0,0,1). +export function heightToNormal(width: number, height: number, + rgba: Uint8Array, strength: number): Uint8Array { + const w = width, h = height; + const lum = (x: number, y: number): number => { + const xi = ((x % w) + w) % w, yi = ((y % h) + h) % h; + const o = (yi * w + xi) * 4; + return (0.299 * rgba[o] + 0.587 * rgba[o + 1] + 0.114 * rgba[o + 2]) / 255; + }; + const out = new Uint8Array(w * h * 4); + let o = 0; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + // Sobel gradient of the height field. + const gx = (lum(x + 1, y - 1) + 2 * lum(x + 1, y) + lum(x + 1, y + 1)) + - (lum(x - 1, y - 1) + 2 * lum(x - 1, y) + lum(x - 1, y + 1)); + const gy = (lum(x - 1, y + 1) + 2 * lum(x, y + 1) + lum(x + 1, y + 1)) + - (lum(x - 1, y - 1) + 2 * lum(x, y - 1) + lum(x + 1, y - 1)); + let nx = -gx * strength, ny = -gy * strength, nz = 1.0; + const l = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1; + nx /= l; ny /= l; nz /= l; + out[o++] = Math.floor((nx * 0.5 + 0.5) * 255); + out[o++] = Math.floor((ny * 0.5 + 0.5) * 255); + out[o++] = Math.floor((nz * 0.5 + 0.5) * 255); + out[o++] = 255; + } + } + return out; +} + +// Derive a glTF metallic-roughness map from an albedo: G = roughness, B = +// metallic (0 here — all dielectric), R/A unused (kept 255). Roughness is +// driven by albedo luminance so recessed/dark detail (mortar, grooves, plank +// seams) reads rougher/matte and lighter faces a touch glossier. `hi` is the +// roughness at luma 0, `lo` at luma 1 (linear between). Linear data — the +// loader uploads it Unorm and the shader reads G/B directly (no sRGB decode). +export function roughnessMR(width: number, height: number, rgba: Uint8Array, + lo: number, hi: number): Uint8Array { + const out = new Uint8Array(width * height * 4); + for (let i = 0; i < width * height; i++) { + const luma = (0.299 * rgba[i * 4] + 0.587 * rgba[i * 4 + 1] + 0.114 * rgba[i * 4 + 2]) / 255; + const rough = hi - luma * (hi - lo); + const o = i * 4; + out[o] = 255; // R (occlusion ch — unused here) + out[o + 1] = Math.max(0, Math.min(255, Math.floor(rough * 255))); // G = roughness + out[o + 2] = 0; // B = metallic (dielectric) + out[o + 3] = 255; + } + return out; +} + // Grass texture: green fbm + scattered brighter / darker tufts + occasional // dirt specks. Seamless wrap via modular hash inputs. export function grassTexture(size: number): Uint8Array { const s = size; + const cb = (n: number) => (n < 0 ? 0 : n > 255 ? 255 : Math.floor(n)); + // fbmp returns [0,1] and tiles seamlessly over u,v ∈ [0,1). return makeTexture(s, s, (x, y) => { const u = x / s, v = y / s; - // Base fbm noise (wraps via *0.5 trick — not truly seamless but close - // enough for distant ground). - const n = fbm(u * 6, v * 6, 4); - // Small-scale "blade" noise. - const n2 = fbm(u * 32, v * 32, 2); - // Mix greens. - const r = Math.floor(52 + n * 40 + n2 * 12); - const g = Math.floor(92 + n * 60 + n2 * 20); - const b = Math.floor(38 + n * 30 + n2 * 10); - // Occasional dirt speck. - const speck = hash2((x * 17) | 0, (y * 17) | 0); - if (speck > 0.985) return [110, 82, 50, 255]; + // Three seamless scales: big lush/dry patches, medium tufts, fine blades. + const patch = fbmp(u, v, 4, 3); + const clump = fbmp(u, v, 3, 10); + const blade = fbmp(u, v, 2, 38); + // Dryness drives a lush-green → olive shift across large areas. + const dry = Math.max(0, Math.min(1, (patch - 0.34) / 0.40)); + let r = 58 + dry * 54; // lush 58 → dry/olive 112 + let g = 112 + dry * 6; // stays green, dry a touch lighter + let b = 46 + dry * 16; // lush cool → dry warm + // Tuft shading: clumps catch light (brighter), gaps recede (darker). A + // little extra green in the tuft highlights reads as fresh blades. Floor + // the multiplier so gaps darken without going muddy/near-black. + const shade = Math.max(0.72, 1 + (clump - 0.5) * 0.4 + (blade - 0.5) * 0.32); + r *= shade; + g = g * shade + (clump - 0.5) * 26; + b *= shade; + // Sparse dirt/earth patches (low-freq, only the high tail) for variety. + const dirt = fbmp(u, v, 3, 5); + if (dirt > 0.66) { + const dt = Math.min(1, (dirt - 0.66) / 0.14); + r = r + (118 - r) * dt; + g = g + (88 - g) * dt; + b = b + (58 - b) * dt; + } + return [cb(r), cb(g), cb(b), 255]; + }); +} + +// Leaf-cluster cutout texture (RGBA with a real alpha channel) for PUBG-style +// foliage cards: clumpy green leaves with transparent gaps, so an alpha-tested +// quad reads as a clump of leaves instead of a solid card. The alpha is hard +// (0/255) for crisp MASK cutout, faded to transparent near the card border so +// the quad's square edge disappears. +export function leafTexture(size: number): Uint8Array { + const s = size; + // NOTE: this project's fbm/noise2 are zero-centred (~[-0.3, 0.3]), not [0,1] + // — they're designed to be ADDED to a base. Remap accordingly. + return makeTexture(s, s, (x, y) => { + const u = x / s, v = y / s; + const clump = fbm(u * 4.5, v * 4.5, 4); // ~[-0.3, 0.3] + const detail = fbm(u * 22, v * 22, 3); + // Clump density centred at 0.5; big clumps dominate, detail nibbles edges. + let mask = 0.5 + clump * 1.7 + detail * 0.8; + // Fade the card border to transparent so quads don't show a hard square. + const edge = Math.min(Math.min(u, 1 - u), Math.min(v, 1 - v)); + mask *= fade(Math.min(1, edge / 0.12)); + const a = mask > 0.42 ? 255 : 0; + // Green with leaf variation (remap the ±0.3 noise to ~0.2..0.8). + const cd = clump + 0.5, dd = detail + 0.5; + const yellow = noise2(u * 9 + 3.1, v * 9 + 7.7) + 0.5; + const shade = 0.72 + dd * 0.55; + const r = Math.min(255, Math.floor((40 + yellow * 78) * shade)); + const g = Math.min(255, Math.floor((96 + cd * 86) * shade)); + const b = Math.min(255, Math.floor((26 + yellow * 26) * shade)); + return [r, g, b, a]; + }); +} + +// Grass-blade cutout (RGBA) for scattered ground tufts. A spray of thin +// tapering blades — opaque inside each blade, transparent elsewhere — so an +// alpha-MASK card reads as a clump of grass. Card UVs put v=0 at the card +// BOTTOM, and glTF samples v=0 at the texture TOP, so blade ROOTS live at the +// top row (v=0) and TIPS at the bottom (v→tip height); that lands roots at the +// ground end of the card. Tips are brighter, roots darker (fake AO). +export function grassBladeTexture(size: number): Uint8Array { + const s = size; + return makeTexture(s, s, (x, y) => { + const u = x / s, v = y / s; // v: 0 = root end, increasing toward tips + let a = 0, shade = 0; + const NB = 13; + for (let i = 0; i < NB; i++) { + const base = (i + 0.5) / NB + (hash2(i * 3, 1) - 0.5) * 0.04; + const bh = 0.62 + hash2(i, 11) * 0.36; // tip height (in v) + if (v < bh) { + const lean = (hash2(i, 17) - 0.5) * 0.55; + const cx = base + lean * (v / bh); // blade curves over with height + const w = (0.016 + hash2(i, 7) * 0.018) * (1 - (v / bh) * 0.85); + if (Math.abs(u - cx) < w) { + a = 255; + const sh = 0.42 + (v / bh) * 0.55 + hash2(i, 5) * 0.14; + if (sh > shade) shade = sh; + } + } + } + if (a === 0) return [0, 0, 0, 0]; + const g = Math.min(255, Math.floor(72 + shade * 118)); + const r = Math.min(255, Math.floor(40 + shade * 50)); + const b = Math.min(255, Math.floor(26 + shade * 30)); + return [r, g, b, a]; + }); +} + +// Wildflower cutout (RGBA): a few thin green stems each topped with a small +// coloured blossom (white / yellow / pink / lilac), on transparent. Same UV +// orientation convention as grassBladeTexture (v=0 = root/card-bottom). Adds +// colour pops to the otherwise all-green meadow. +export function flowerTexture(size: number): Uint8Array { + const s = size; + const palR = [240, 248, 232, 188]; + const palG = [240, 224, 120, 158]; + const palB = [248, 96, 150, 238]; + const palN = 4; + return makeTexture(s, s, (x, y) => { + const u = x / s, v = y / s; // v: 0 root → increasing toward tip + let a = 0; + let cr = 50, cg = 110, cb = 40; + const NB = 6; + for (let i = 0; i < NB; i++) { + const base = (i + 0.5) / NB + (hash2(i * 3, 1) - 0.5) * 0.05; + const bh = 0.55 + hash2(i, 11) * 0.30; // blossom height (in v) + const lean = (hash2(i, 17) - 0.5) * 0.40; + // Stem. + if (v < bh) { + const cx = base + lean * (v / bh); + const w = 0.011 * (1 - (v / bh) * 0.5); + if (Math.abs(u - cx) < w) { a = 255; cr = 46; cg = 104; cb = 36; } + } + // Blossom at the tip. + const bcx = base + lean; + const dx = u - bcx, dy = v - bh; + const br = 0.045 + hash2(i, 23) * 0.022; + const dd = Math.sqrt(dx * dx + dy * dy); + if (dd < br) { + a = 255; + // hash2 here is SIGNED (~[-1,1]); take the fractional part for a safe + // [0,1) → palette index (a raw negative index would read undefined). + const hv = hash2(i, 31); + const fr = hv - Math.floor(hv); + const idx = Math.min(palN - 1, Math.floor(fr * palN)); + if (dd < br * 0.38) { cr = 250; cg = 210; cb = 70; } // yellow centre + else { cr = palR[idx]; cg = palG[idx]; cb = palB[idx]; } + } + } + if (a === 0) return [0, 0, 0, 0]; + return [cr, cg, cb, 255]; + }); +} + +// Bark texture: vertical brown fibres with darker grooves + knots, for the +// tree trunk. Opaque. +export function barkTexture(size: number): Uint8Array { + const s = size; + return makeTexture(s, s, (x, y) => { + const u = x / s, v = y / s; + // Vertical fibres: high-freq in u (around the trunk), low-freq in v (up). + // fbm is zero-centred (~±0.3) — remap to ~0.2..0.8. + const fibre = fbm(u * 26, v * 4, 4) + 0.5; + const groove = Math.sin(u * Math.PI * 2 * 14 + fbm(u * 5, v * 5, 2) * 6) * 0.5 + 0.5; + const knot = fbm(u * 3, v * 3, 3) + 0.5; + const g0 = 0.6 + groove * 0.4; + const shade = (0.6 + fibre * 0.5) * g0 * (0.85 + knot * 0.3); + const r = Math.min(255, Math.floor(95 * shade + 30)); + const g = Math.min(255, Math.floor(66 * shade + 22)); + const b = Math.min(255, Math.floor(44 * shade + 14)); return [r, g, b, 255]; }); } +// Stone-block wall texture. Running-bond courses of warm-grey blocks with +// recessed dark mortar, per-block tint variation, edge AO, and two scales of +// surface grain. Seamless (fbmp + integer block grid). Tiles at TILE_METRES +// (2 m) in the prop builder, so 3 cols × 4 rows ≈ 0.66 m blocks. +export function stoneTexture(size: number): Uint8Array { + const s = size; + const cb = (n: number) => (n < 0 ? 0 : n > 255 ? 255 : Math.floor(n)); + const COLS = 3, ROWS = 4, MORTAR = 0.06; + return makeTexture(s, s, (x, y) => { + const u = x / s, v = y / s; + const rowF = v * ROWS; + const row = Math.floor(rowF); + const fbv = rowF - row; + const bu = u * COLS + (row % 2) * 0.5; // running-bond half-offset + const col = Math.floor(bu); + const fbu = bu - col; + const eu = Math.min(fbu, 1 - fbu); + const ev = Math.min(fbv, 1 - fbv); + const m = Math.min(eu, ev); // distance to nearest mortar + const bid = hash2(col + (row % 2) * 977, row); // stable per-block tint + const grain = fbmp(u, v, 4, 24); // fine pitting + const blot = fbmp(u, v, 3, 6); // larger weathering blotches + let base = 98 + bid * 42 + (grain - 0.5) * 44 + (blot - 0.5) * 30; + // Edge AO: blocks darken toward their borders (chamfer/shadow). + base *= 0.78 + 0.22 * Math.min(1, m / 0.16); + let r = base * 1.04, g = base * 0.99, b = base * 0.88; + if (m < MORTAR) { const d = 0.46; r *= d; g *= d; b *= d; } // recessed mortar + return [cb(r), cb(g), cb(b), 255]; + }); +} + +// Wood-plank texture (crates / props). Vertical planks separated by dark gaps, +// each plank a slightly different warm-brown tone, with along-grain streaks. +export function woodTexture(size: number): Uint8Array { + const s = size; + const cb = (n: number) => (n < 0 ? 0 : n > 255 ? 255 : Math.floor(n)); + const PLANKS = 4; + return makeTexture(s, s, (x, y) => { + const u = x / s, v = y / s; + const pf = u * PLANKS; + const p = Math.floor(pf); + const fp = pf - p; + const pid = hash2(p, 7); + // Grain runs along the plank (v): high-freq across (u), low along (v). + const grain = fbmp(u, v * 0.5, 4, 32); + const streak = Math.sin((fp + (grain - 0.5) * 0.6) * Math.PI * 3.0) * 0.5 + 0.5; + let base = 120 + pid * 40 + (grain - 0.5) * 36 + streak * 22; + const gap = Math.min(fp, 1 - fp) < 0.04 ? 0.5 : 1.0; // dark seam between planks + base *= gap; + return [cb(base * 1.0), cb(base * 0.72), cb(base * 0.46), 255]; + }); +} + +// Brushed-metal texture: cool grey with fine horizontal brushing + a few darker +// streaks. Low roughness in the material; this just supplies albedo variation. +export function metalTexture(size: number): Uint8Array { + const s = size; + const cb = (n: number) => (n < 0 ? 0 : n > 255 ? 255 : Math.floor(n)); + return makeTexture(s, s, (x, y) => { + const u = x / s, v = y / s; + const brush = fbmp(u * 0.25, v, 3, 64); // streaky along u + const blot = fbmp(u, v, 3, 8); + let base = 150 + (brush - 0.5) * 60 + (blot - 0.5) * 24; + return [cb(base * 0.96), cb(base * 0.97), cb(base * 1.02), 255]; + }); +} + +// Floor texture: warm wooden boards laid along one axis, darker board seams, +// per-board tone variation. Seamless. +export function floorTexture(size: number): Uint8Array { + const s = size; + const cb = (n: number) => (n < 0 ? 0 : n > 255 ? 255 : Math.floor(n)); + const BOARDS = 4; + return makeTexture(s, s, (x, y) => { + const u = x / s, v = y / s; + const bf = v * BOARDS; + const b = Math.floor(bf); + const fb = bf - b; + const bid = hash2(b, 31); + const grain = fbmp(u * 0.5, v, 4, 40); + let base = 132 + bid * 34 + (grain - 0.5) * 40; + const seam = Math.min(fb, 1 - fb) < 0.035 ? 0.55 : 1.0; + base *= seam; + return [cb(base * 0.92), cb(base * 0.68), cb(base * 0.44), 255]; + }); +} + // Water texture: deep-blue gradient with ripple lines + lighter highlights. // UV flow scrolling in the shader gives the sense of current. export function waterTexture(size: number): Uint8Array { diff --git a/tools/preview-grass.ts b/tools/preview-grass.ts new file mode 100644 index 0000000..d8f58ec --- /dev/null +++ b/tools/preview-grass.ts @@ -0,0 +1,37 @@ +// Dev-only: render procedural textures to PNGs for eyeballing. Not in the build. +import { encodePng, makeTexture, + grassTexture, stoneTexture, woodTexture, floorTexture, grassBladeTexture, flowerTexture } from './png'; +import { writeFileSync } from 'node:fs'; + +function tile3(name: string, tex: Uint8Array, TILE: number) { + const REP = 3, W = TILE * REP, H = TILE * REP; + const tiled = makeTexture(W, H, (x, y) => { + const sx = x % TILE, sy = y % TILE; + const o = (sy * TILE + sx) * 4; + return [tex[o], tex[o + 1], tex[o + 2], 255]; + }); + writeFileSync(`tools/.testout/${name}.png`, encodePng(W, H, tiled)); + console.log('wrote', name, W, 'x', H); +} + +// Composite an RGBA cutout over a sky-blue background so the alpha shape shows. +function overBg(name: string, tex: Uint8Array, S: number) { + const out = makeTexture(S, S, (x, y) => { + const o = (y * S + x) * 4; + const a = tex[o + 3] / 255; + const bg = [120, 150, 190]; + return [ + Math.floor(tex[o] * a + bg[0] * (1 - a)), + Math.floor(tex[o + 1] * a + bg[1] * (1 - a)), + Math.floor(tex[o + 2] * a + bg[2] * (1 - a)), + 255, + ]; + }); + writeFileSync(`tools/.testout/${name}.png`, encodePng(S, S, out)); + console.log('wrote', name, S, 'x', S); +} + +tile3('grass_preview', grassTexture(256), 256); +tile3('stone_preview', stoneTexture(512), 512); +overBg('blade_preview', grassBladeTexture(256), 256); +overBg('flower_preview', flowerTexture(256), 256); diff --git a/tools/shot.ps1 b/tools/shot.ps1 new file mode 100644 index 0000000..465f712 --- /dev/null +++ b/tools/shot.ps1 @@ -0,0 +1,38 @@ +# Build the shooter and capture one gameplay screenshot via geisterhand. +# Usage: powershell -ExecutionPolicy Bypass -File tools/shot.ps1 -Name foo [-Settle 6] +# Settle defaults to 12s: the deferred pipeline (shadow maps, TAA accumulation, +# auto-exposure adaptation) needs several seconds to converge, and the D3D12 +# window must be foreground for geisterhand to capture a non-blank frame. +param([string]$Name = 'shot', [int]$Settle = 12) +$ErrorActionPreference = 'Stop' +$proj = 'C:\Users\Ralph\projects\bloom\shooter' +$gh = 'C:\Users\Ralph\projects\geisterhandwindows\publish\geisterhand.exe' +Set-Location $proj +# Kill any running instance first — it locks main.exe and the link can't overwrite it. +Get-Process main,geisterhand -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +Start-Sleep -Milliseconds 400 +$b = cmd /c "perry compile src/main.ts -o main 2>&1" +if ($LASTEXITCODE -ne 0) { Write-Host "BUILD FAILED:" -ForegroundColor Red; $b | Select-Object -Last 14; exit 1 } +Write-Host "build ok" +$srv = Start-Process $gh -ArgumentList @('server','--port','7676') -PassThru -WindowStyle Hidden +$game = Start-Process "$proj\main.exe" -WorkingDirectory $proj -PassThru -RedirectStandardError "$proj\_run_err.txt" +Start-Sleep -Seconds $Settle +$dst = "$proj\tools\.testout\$Name.png" +if (-not $game.HasExited) { + # The D3D12 game window only presents capturable frames while it is the + # foreground window — geisterhand otherwise captures a blank/white surface. + Add-Type -AssemblyName Microsoft.VisualBasic + try { [Microsoft.VisualBasic.Interaction]::AppActivate($game.Id) | Out-Null } catch {} + Start-Sleep -Milliseconds 600 + try { + $r = Invoke-RestMethod -Uri "http://127.0.0.1:7676/screenshot?pid=$($game.Id)&format=png" -TimeoutSec 15 + $bytes = [Convert]::FromBase64String($r.image) + [IO.File]::WriteAllBytes($dst, $bytes) + $n=[Math]::Min($bytes.Length,60000); $sum=0; for($i=0;$i -lt $n;$i++){$sum+=$bytes[$i]} + Write-Host ("saved $dst ($($r.width)x$($r.height)) bright=$([Math]::Round($sum/$n,1)) size=$($bytes.Length)") -ForegroundColor Green + } catch { Write-Host "screenshot failed: $_" -ForegroundColor Red } +} else { + Write-Host "GAME EXITED early code=$($game.ExitCode)" -ForegroundColor Red + Get-Content "$proj\_run_err.txt" -Tail 14 +} +Get-Process main,geisterhand -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue