lecodes-cli 1.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/dist/index.js +6211 -3649
  2. package/package.json +12 -8
  3. package/runtime/materials/lightmap.mat +115 -139
  4. package/runtime/materials/particles-quad.mat +6 -2
  5. package/runtime/materials/terrain-lightmap.mat +123 -0
  6. package/runtime/materials/terrain.mat +36 -55
  7. package/runtime/sdk-types.json +1 -1
  8. package/runtime/web/VERSION.json +5 -0
  9. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  10. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  11. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  12. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  13. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  14. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  15. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  16. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  17. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  18. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  19. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  20. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  21. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  22. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  23. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  24. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  25. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  26. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  27. package/runtime/web/index.html +36 -0
  28. package/runtime/web/player.js +21 -0
  29. package/runtime/web-page/index.html +41 -0
  30. package/runtime/web-page/page.css +1 -0
  31. package/runtime/web-page/page.js +31 -0
  32. package/src/cli/help.ts +25 -0
  33. package/src/cli/index.ts +7 -7
  34. package/src/commands/app/android.ts +3 -1
  35. package/src/commands/app/desktop.ts +1 -1
  36. package/src/commands/app/desktopMac.ts +10 -9
  37. package/src/commands/app/icon.ts +9 -6
  38. package/src/commands/app/index.ts +3 -1
  39. package/src/commands/app/shared.ts +13 -5
  40. package/src/commands/app/templates/android.ts +10 -11
  41. package/src/commands/app/templates/ios.ts +1 -1
  42. package/src/commands/assets.ts +1 -1
  43. package/src/commands/compile.ts +15 -2
  44. package/src/commands/design/index.ts +4 -4
  45. package/src/commands/design/snapshot.ts +3 -3
  46. package/src/commands/desktop.ts +5 -1
  47. package/src/commands/dev.ts +82 -29
  48. package/src/commands/index.ts +64 -60
  49. package/src/commands/init/index.ts +11 -15
  50. package/src/commands/init/templates.ts +64 -21
  51. package/src/commands/lightmap.ts +459 -200
  52. package/src/commands/navmesh.ts +2 -2
  53. package/src/commands/plugin.ts +46 -0
  54. package/src/commands/render.ts +22 -16
  55. package/src/commands/scene.ts +6 -1
  56. package/src/commands/serve.ts +58 -0
  57. package/src/commands/shaders/new.ts +1 -1
  58. package/src/commands/test.ts +402 -398
  59. package/src/commands/update/index.ts +3 -3
  60. package/src/compile/designCompile.ts +1 -1
  61. package/src/compile/headlessBundle.ts +133 -133
  62. package/src/compile/nativeStack.ts +1 -1
  63. package/src/compile/projectCompile.ts +10 -2
  64. package/src/compile/sceneCompile.ts +8 -22
  65. package/src/declarations/lecodes-3d-editor.d.ts +4 -0
  66. package/src/declarations/lecodes-assets.d.ts +1 -1
  67. package/src/declarations/{lecodes-renderer.d.ts → lecodes-headless.d.ts} +3 -3
  68. package/src/dev/clientTemplates.ts +18 -14
  69. package/src/dev/devServer.ts +75 -38
  70. package/src/dev/iosDev.ts +180 -0
  71. package/src/dev/webRunner.ts +82 -14
  72. package/src/hosts/cmgenTool.ts +1 -1
  73. package/src/hosts/desktopRenderer.ts +98 -27
  74. package/src/hosts/desktopScript.ts +150 -35
  75. package/src/hosts/distRoot.ts +1 -1
  76. package/src/hosts/matcTool.ts +1 -1
  77. package/src/hosts/peerInstall.ts +5 -5
  78. package/src/hosts/peers.ts +2 -2
  79. package/src/index.ts +4 -1
  80. package/src/plugin/contract.ts +438 -0
  81. package/src/plugin/emitKotlin.ts +357 -0
  82. package/src/plugin/emitSdk.ts +259 -0
  83. package/src/plugin/emitSwift.ts +336 -0
  84. package/src/plugin/emitWeb.ts +383 -0
  85. package/src/plugin/generate.ts +157 -0
  86. package/src/plugin/lines.ts +28 -0
  87. package/src/plugin/lower.ts +231 -0
  88. package/src/plugin/manifest.ts +192 -0
  89. package/src/project/ignore.ts +164 -163
  90. package/runtime/materials/lightmap-baked-lite.mat +0 -180
  91. package/runtime/materials/lightmap-baked.mat +0 -181
  92. package/runtime/scene-harness.json +0 -1
  93. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  94. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  95. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  96. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +0 -866
  97. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  98. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  99. package/runtime/web/assets/index-Q06c6oHx.js +0 -2
  100. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  101. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  102. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  103. package/runtime/web/embed.html +0 -32
  104. package/runtime/web/embed.js +0 -171
package/package.json CHANGED
@@ -1,23 +1,26 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "1.0.0",
3
+ "version": "2.0.1",
4
4
  "dependencies": {
5
- "@letary/chisel": "^0.10.0",
5
+ "@letary/chisel": "^0.13.0",
6
6
  "jimp": "^1.6.1",
7
- "lecodes-sdk": "1.0.0"
7
+ "lecodes-sdk": "2.0.1"
8
8
  },
9
9
  "devDependencies": {
10
10
  "lecodes-design": "^0.9.0",
11
- "lecodes-renderer": "1.0.0",
11
+ "lecodes-headless": "2.0.1",
12
12
  "qrcode-terminal": "^0.12.0",
13
13
  "lecodes-3d-editor": "^0.7.0",
14
14
  "lecodes-assets": "0.2.1",
15
15
  "lecodes-creator-nav": "0.19.0",
16
- "lecodes-viewer-lite": "1.0.0"
16
+ "lecodes-gl-lite": "2.0.1",
17
+ "lecodes-web-frame": "2.0.1",
18
+ "lecodes-web-player": "2.0.1",
19
+ "lecodes-web-shared": "2.0.1"
17
20
  },
18
21
  "peerDependencies": {
19
22
  "lecodes-design": "^0.9.0",
20
- "lecodes-renderer": "^1.0.0",
23
+ "lecodes-headless": "^2.0.1",
21
24
  "lecodes-3d-editor": "^0.7.0",
22
25
  "lecodes-assets": "^0.2.1"
23
26
  },
@@ -43,7 +46,7 @@
43
46
  "lecodes-design": {
44
47
  "optional": true
45
48
  },
46
- "lecodes-renderer": {
49
+ "lecodes-headless": {
47
50
  "optional": true
48
51
  },
49
52
  "lecodes-3d-editor": {
@@ -57,9 +60,10 @@
57
60
  "icon": "bun scripts/make-icon.ts",
58
61
  "vendor-runtime": "bun scripts/vendor-runtime.ts",
59
62
  "sync-peers": "bun scripts/sync-peer-versions.ts",
60
- "build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --external lecodes-3d-editor --external lecodes-assets --banner=\"#!/usr/bin/env node\"",
63
+ "build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-headless --external lecodes-design --external lecodes-3d-editor --external lecodes-assets --banner=\"#!/usr/bin/env node\"",
61
64
  "build:bin": "bun scripts/build-bin.ts",
62
65
  "typecheck": "tsc --noEmit",
66
+ "test": "bun test",
63
67
  "docs": "bun scripts/gen-docs.ts",
64
68
  "docs:check": "bun scripts/gen-docs.ts --check",
65
69
  "dev": "bun run src/index.ts",
@@ -1,43 +1,89 @@
1
- // lightmap — a lit PBR material that multiplies a BAKED shadow mask into the result and adds the
2
- // BAKED direct light of the level's point lights (docs/lightmap-plan.md §3). gltfio hands GLB
3
- // primitives this material instead of the ubershader when the SDK loads a model with
4
- // `lightmap: true` (LightmapMaterialProvider in creator-gl/src/glb.cpp), so the base-colour
5
- // parameters use gltfio's own names and the AssetLoader fills them itself. Mesh receivers
6
- // (Mesh.plane) get it from the SDK directly.
1
+ // lightmap — the material of a BAKED static (creator-bake, phase 1). gltfio hands a GLB's primitives this
2
+ // material instead of the ubershader when the SDK loads a model with `lightmap: true` (the provider in
3
+ // creator-gl/src/glb.cpp), so the glTF-side parameters use gltfio's own names and the AssetLoader fills
4
+ // them itself. Mesh receivers get it from the SDK directly.
7
5
  //
8
- // Atlas channels: R = sun visibility, G = ambient occlusion. G goes to ambientOcclusion, which
9
- // filament applies to the IBL term exactly (diffuse + specular AO). R attenuates the sun through
10
- // postLightingColor (multiply) — it scales the WHOLE lit result, so `k` keeps the ambient share
11
- // alive in shadow: k = (ambient·G + sun·NoL·vis) / (ambient·G + sun·NoL), diffuse-exact.
12
- // The real-time sun still lights this surface, so dynamic casters keep their shadow-map shadows.
6
+ // A static takes ALL of its diffuse light from the atlas: `lightmapLight` holds the irradiance E in lux
7
+ // (BC6H, times lightScale) that the bake traced onto the surface — the sun, the sky and the lamps
8
+ // together — and the pixel's diffuse is albedo x E / pi, Filament's own punctual diffuse. Nothing
9
+ // real-time is added on top: the engine keeps the sun and the lamps off these renderables (light channels,
10
+ // lightmapApply) and Filament's IBL — diffuse and specular — is switched off exactly (ambientOcclusion = 0,
11
+ // specularAmbientOcclusion simple). The SPECULAR comes from the bake too: with the level's REFLECTION PROBES
12
+ // bound (phase 4, `lightmapProbe` + the probe map under the aux page: prefiltered radiance traced from points of the
13
+ // level, creator-bake/src/reflect.h) it is the split-sum response to the radiance the probes this texel SEES (baked)
14
+ // hold along the reflection — blended per pixel, parallax-corrected by each probe's room box, at the material's
15
+ // roughness — held down to this texel's own light where the probes see more than the surface does (a specular
16
+ // occlusion from the bake, see the fragment); without them, the baked light as light from all over plus the
17
+ // sky's SH radiance along the reflection through the baked sky visibility. No
18
+ // highlight from the baked direction (fireflies, see the fragment). Plus the glTF emissive. So the picture on
19
+ // a static is the bake, and a wrong picture is a wrong bake or a wrong material, never a shading estimate.
13
20
  //
14
- // The LIGHT atlas (lightmapLight, same rects) holds the point lights' direct irradiance E as
15
- // sRGB(E / lightScale): the bake traced colour × candela × filament's own falloff window × NoL ×
16
- // shadow rays, so the baked lamp matches the real-time one it replaces (the SDK switches the
17
- // real-time light off once the bake applies). It reaches the pixel as emissive = diffuseColor ×
18
- // E / π, exposure-weighted — the diffuse term filament's own punctual path would have produced.
19
- // No specular from baked lights (v1).
21
+ // The aux atlas (`lightmapAux`, RGBA8 linear, the same rects): RGB = share x d (x 0.5 + 0.5) — the
22
+ // directional SHARE of the baked light times its direction d — and A = sky visibility (phase 3,
23
+ // creator-bake/src/direction.h). The
24
+ // bake split every texel's light into an ambient part (uniform over the hemisphere) and one direction, so
25
+ // a normal map's shading normal ns gets the irradiance those two would give it:
26
+ // E(ns) = E x [ (1 - share) (1 + n.ns) / 2 + share max(0, d.ns) / (d.n) ]
27
+ // exact for both parts, and E itself where the normal is flat. Under a grazing light (d.n < 0.1) the bake
28
+ // fades the share out and the slope is capped. `reconstructIrradiance` in direction.h is the same formula;
29
+ // bake-check tests it.
20
30
  //
21
- // Sampler budget: feature level 1 allows 9 per material (16 minus filament's 7 for a lit shader);
22
- // this one uses all 9 — nothing else can be added without dropping one.
31
+ // DEBUG VIEWS (`debugMode`, level-wide through lightmapDebug): the material writes DATA to the pixel
32
+ // and turns the lit path off (black base colour, no reflectance), so what is on screen is the number —
33
+ // look at it under `toneMapping: 'linear'` for a faithful reading.
34
+ // 1 lighting only: a white diffuse material under the camera's exposure 2 albedo (base colour, linear)
35
+ // 3 geometric normal (x 0.5 + 0.5) 4 shading normal (the normal map applied)
36
+ // 5 the directional share (0 = ambient, 1 = one direction) 6 sky visibility
37
+ // 7 the atlas: a texel checker, tinted per instance 8 the baked light's direction (x 0.5 + 0.5)
38
+ // 9 false-colour lux: log10 over [debugParams.y, debugParams.z] 10 relief: E(ns) / E, grey 0.5 = 1 (flat)
39
+ // 13 probe: the radiance the specular reflects (the probe along the reflection at the material's roughness, occluded;
40
+ // or the sky + baked light without a probe), under the camera's exposure like view 1
41
+ // 14 the probe map: grey = the probes' share Wp of the reflection, tinted by the texel's probe pair (magenta = no entry)
42
+ // 15 the sky's path of the reflection alone 16 the probes' path alone, after the occlusion (black = no probe)
43
+ // On desktop both atlases are fetched bicubically (textureBicubic: a cubic B-spline through four bilinear taps) —
44
+ // the texel grid stops showing on the diagonals of a shadow's edge; the bake's gutters (4 texels, chart-aware fill)
45
+ // cover the wider 4x4 footprint. Mobile and iOS take the plain bilinear fetch (matc -TLIGHTMAP_FETCH, materials.json);
46
+ // macOS (`materials-macos`) takes the desktop flags: the same MOBILE shader model as iOS, the desktop's quality.
47
+ //
48
+ // PRECISION: everything in LUX is `highp`, explicitly. The mobile shader model (matc -p mobile: the `materials` and
49
+ // `materials-ios` targets) makes mediump the fragment default, and Filament's Metal path turns mediump into 16-bit
50
+ // half (filamat GLSLPostProcessor: CreateConvertRelaxedToHalfPass) — max 65504. A sunlit texel is 100 000-180 000 lux
51
+ // once the atlas is multiplied back by lightScale, so on macOS / iOS every baked static went infinite (flat white,
52
+ // and bloom smeared it over the frame: the yard, 2026-09-17) while the desktop target (highp by default) was right.
53
+ // The parameters in lux are `precision : high` for the same reason. Ratios and exposed values are O(1) and may stay.
54
+ // The same for ATLAS COORDINATES, TEXEL INDICES, PROBE INDICES and WORLD POSITIONS: a half has 11 mantissa bits, so a
55
+ // uv near 1 steps by 1/2048 (two texels of a 4096 page — the irradiance read as blocks on macOS, 2026-09-17), an
56
+ // integer above 2048 cannot be odd (the texel checker, view 7, went flat), and 65534 / 65535 (the sky / none probe
57
+ // sentinels) are not representable at all. `luv`, the bicubic taps, mapU16 and the probe ids are highp for that.
23
58
  material {
24
59
  name : lightmap,
25
60
  shadingModel : lit,
26
61
  doubleSided : true,
27
62
  // opaque or masked: matc -TBLENDING (materials.json `variants`) — the masked twin is what the
28
- // lightmap provider hands a glTF MASK material (foliage cards), same shader otherwise
63
+ // provider hands a glTF MASK material; BLEND materials (glass) are not lightmapped at all
29
64
  blending : ${BLENDING},
30
- // glTF's UV origin is top-left, like gltfio's ubershader (base.mat.in): no flip — filament's
31
- // default (true) would mirror both the props' base colour and the atlas rects vertically.
65
+ // glTF's UV origin is top-left, like gltfio's ubershader (base.mat.in): no flip
32
66
  flipUV : false,
33
67
  requires : [ uv0, uv1 ],
34
- postLightingBlending : multiply,
68
+ // Filament's own specular occlusion, fed ambientOcclusion = 0: the IBL — diffuse AND specular — is off
69
+ // on a baked surface. Occluding the specular through f0 alone (2026-09-16, phase 1) leaked the sky at
70
+ // grazing angles: Schlick's (1 - NoV)^5 term does not care about f0, so a basement floor mirrored the
71
+ // sky IBL whenever the camera looked along it. The sky reflection is the material's own now (skyRadianceSH
72
+ // x skyVis in the fragment); sharp interior reflections need local probes (a later phase).
73
+ specularAmbientOcclusion : simple,
35
74
  parameters : [
36
75
  { type : float4, name : baseColorFactor },
37
76
  { type : sampler2d, name : baseColorMap },
38
77
  { type : float, name : metallicFactor },
39
78
  { type : float, name : roughnessFactor },
40
79
  { type : float3, name : emissiveFactor },
80
+ // KHR_materials_emissive_strength (gltfio sets it, 1 without the extension): an HDR panel is factor 1 x strength 10
81
+ { type : float, name : emissiveStrength },
82
+ // THE LEVEL'S RADIANCE OF EMISSION 1.0, cd / m2 (the bake's emissiveNits, level-wide): over 0 the glow is drawn
83
+ // SCENE-referred - emission x nits through the camera's exposure, the luminance the bake lit the room with - so a
84
+ // panel looks as bright as the light it gives. 0 = screen-referred as before (emission 1.0 = white at any exposure;
85
+ // an imported strength of 10 then blooms over the whole ceiling).
86
+ { type : float, name : emissiveNits, precision : high },
41
87
  // set unconditionally by gltfio's AssetLoader (a missing uniform is a PANIC, not a no-op)
42
88
  { type : float, name : normalScale },
43
89
  { type : float, name : aoStrength },
@@ -46,139 +92,69 @@ material {
46
92
  { type : sampler2d, name : normalMap },
47
93
  { type : float, name : hasNormalMap },
48
94
  // glTF metal-rough (B = metallic, G = roughness), occlusion (R) and emissive maps on UV0 —
49
- // bound by the AssetLoader by name; absent = the shared 1x1 white, which is each map's
50
- // identity under its factor (metallic 0 x 1, roughness x 1, AO 1, emissive 0 x 1)
95
+ // bound by the AssetLoader by name; absent = the shared 1x1 white, each map's identity
51
96
  { type : sampler2d, name : metallicRoughnessMap },
52
97
  { type : sampler2d, name : occlusionMap },
53
98
  { type : sampler2d, name : emissiveMap },
54
- { type : sampler2d, name : lightmap },
55
- // the point lights' baked irradiance, sRGB(E / lightScale) on the same rects; lightScale 0 = none
99
+ // the bake: irradiance (lux / lightScale) and the aux page, on uv1 * lightmapST.xy + lightmapST.zw
56
100
  { type : sampler2d, name : lightmapLight },
57
- { type : float, name : lightScale },
58
- // the light store's curve: texel = sRGB(pow(E / lightScale, 1 / lightGamma)). 0/1 = the linear v2
59
- // store, 2 = the square-root store bakes write since 2026-08-31 (headroom for the 1/d² peaks)
60
- { type : float, name : lightGamma },
61
- // uv1 * st.xy + st.zw = the instance's rectangle of the atlas
62
- { type : float4, name : lightmapST },
63
- // multiplier on the ambient (IBL) share in the shadow math: 1 = filament's own shadow
64
- // darkness, >1 lighter shadows, <1 deeper
65
- { type : float, name : ambientScale },
66
- // 1 = baked sun shadows at full strength, 0 = R ignored (AO only)
67
- { type : float, name : sunStrength },
68
- // how much of the baked ambient occlusion applies: 1 = all of it, 0 = none (an interior lit
69
- // by its ambient probe alone can need less than the geometric truth)
70
- { type : float, name : bakedAo },
71
- // The light VOLUME for dynamic objects (docs/lightmap-plan.md §9): the same channels on a 3D
72
- // grid over the level, sampled at the fragment's world position + probeMin.w along the normal.
73
- // probeInvSize.w = the grid's depth nz (>= 1) selects it over the atlas (creator-gl binds it; an
74
- // atlas rect resets it to 0). probeLight = the point lights' irradiance as an AMBIENT CUBE: six
75
- // face slabs (+X −X +Y −Y +Z −Z) stacked along z, depth nz * 6, blended by the normal's n²
76
- { type : sampler3d, name : probeVolume },
77
- { type : sampler3d, name : probeLight },
78
- { type : float4, name : probeMin },
79
- { type : float4, name : probeInvSize }
101
+ { type : sampler2d, name : lightmapAux },
102
+ { type : float4, name : lightmapST, precision : high },
103
+ // the level's reflection probes (phase 4, creator-bake/src/reflect.h): the octahedral ATLAS of every probe's
104
+ // roughness levels (radiance in lux / lightScale per steradian; level l = perceptual roughness l / (levels - 1)).
105
+ // WHICH probes a pixel reflects is baked: under every aux page sits its PROBE MAP (rows past the square page,
106
+ // read by texelFetch — see probeGather). probeParams = (bound, levels - 1, oct = level 0's tile edge, probes
107
+ // per atlas row); probeGrid.xyz / probeGridN.xyz = the origin / size of the range the map's 16-bit probe records
108
+ // are shares of. probeBall = k + 1 shows probe k alone, unoccluded (the debug mirror balls). Level-wide.
109
+ { type : sampler2d, name : lightmapProbe },
110
+ { type : float4, name : probeParams },
111
+ { type : float4, name : probeGrid, precision : high },
112
+ { type : float4, name : probeGridN, precision : high },
113
+ { type : float, name : probeBall },
114
+ // A SMALL STATIC the atlas left out (the bake's minSize; creator-bake/src/objects.h): no rect — its light was baked
115
+ // where it stands, as an AMBIENT CUBE: objectCube[+x -x +y -y +z -z] = (the irradiance rgb in lux a surface facing
116
+ // that way gets at that side of the object — sun, sky, lamps, bounce — and its sky visibility). objectProbes =
117
+ // (probe a, probe b, a's weight — what the probe map's byte holds for a texel; 65535 none, 65534 sky — and 1 = this
118
+ // mode is on). The atlas samplers stay bound to one of the level's pages: the probes' records sit under its aux image.
119
+ { type : float4[6], name : objectCube, precision : high },
120
+ { type : float4, name : objectProbes, precision : high },
121
+ // A MOVER'S SUN SHADOW on the baked surface: sunDir = TO the sun (w = 1: the aux pages end in the SUN MAP - its
122
+ // levels' rows, level l of edge s = S >> l in s / 4 rows: texel (x, y) = channel x & 3 of pixel ((x >> 2) + (y & 3) s / 4,
123
+ // base(l) + (y >> 2)), the texel's baked share of the sun's disc; lightmap-shading.fs sunMapAt), sunLight = the sun's
124
+ // illuminance in lux, rgb, .w = the sun map's level count (the shader picks the level by the pixel's footprint). Where the real-time
125
+ // shadow map (movers only: the baked statics cast nothing) says "in shadow", the sun's part leaves the baked
126
+ // light - no double shadow: a texel the bake already shaded has no sun to lose.
127
+ { type : float4, name : sunDir },
128
+ { type : float4, name : sunLight, precision : high },
129
+ { type : float, name : lightScale, precision : high },
130
+ { type : float, name : debugMode },
131
+ { type : float4, name : debugParams }
80
132
  ]
81
133
  }
82
134
 
83
135
  fragment {
136
+ #include "lightmap-shading.fs"
137
+
84
138
  void material(inout MaterialInputs material) {
139
+ vec3 n = getWorldGeometricNormalVector();
140
+ vec3 ns = n;
85
141
  if (materialParams.hasNormalMap > 0.5) {
86
142
  vec3 tn = texture(materialParams_normalMap, getUV0()).xyz * 2.0 - 1.0;
87
143
  tn.xy *= materialParams.normalScale;
88
144
  material.normal = tn;
145
+ ns = normalize(getWorldTangentFrame() * tn);
89
146
  }
90
147
  prepareMaterial(material);
91
148
 
92
- material.baseColor = texture(materialParams_baseColorMap, getUV0()) * materialParams.baseColorFactor;
149
+ vec4 base = texture(materialParams_baseColorMap, getUV0()) * materialParams.baseColorFactor;
93
150
  vec2 mr = texture(materialParams_metallicRoughnessMap, getUV0()).bg;
94
- material.metallic = materialParams.metallicFactor * mr.x;
95
- material.roughness = materialParams.roughnessFactor * mr.y;
96
- vec3 glow = texture(materialParams_emissiveMap, getUV0()).rgb * materialParams.emissiveFactor;
97
- float occ = 1.0 + materialParams.aoStrength * (texture(materialParams_occlusionMap, getUV0()).r - 1.0);
98
-
99
- vec3 n = getWorldGeometricNormalVector();
100
- vec3 ns = getWorldNormalVector(); // the shading normal (normal map applied)
101
- vec3 luma = vec3(0.2126, 0.7152, 0.0722);
102
- float lg = materialParams.lightGamma > 0.0 ? materialParams.lightGamma : 1.0;
103
- vec2 lm;
104
- vec3 baked; // the point lights' irradiance E(n) on this pixel, physical units
105
- vec3 l; // the direction that light is taken to arrive from (the specular term below)
106
- if (materialParams.probeInvSize.w > 0.5) {
107
- // a dynamic object: the light volume at this pixel (trilinear, half a cell off the surface)
108
- vec3 p = getUserWorldPosition() + n * materialParams.probeMin.w;
109
- vec3 uvw = (p - materialParams.probeMin.xyz) * materialParams.probeInvSize.xyz;
110
- lm = texture(materialParams_probeVolume, uvw).rg;
111
- // the ambient cube: the face slab of each axis' sign, z clamped to the slab's own cell
112
- // centres so the trilinear fetch never blends into the neighbouring face; decode each face
113
- // (the store is a square root), then blend in linear irradiance by n²
114
- float nz = materialParams.probeInvSize.w;
115
- float wz = clamp(uvw.z, 0.5 / nz, 1.0 - 0.5 / nz);
116
- vec3 fx = vec3(uvw.xy, ((ns.x >= 0.0 ? 0.0 : 1.0) + wz) / 6.0);
117
- vec3 fy = vec3(uvw.xy, ((ns.y >= 0.0 ? 2.0 : 3.0) + wz) / 6.0);
118
- vec3 fz = vec3(uvw.xy, ((ns.z >= 0.0 ? 4.0 : 5.0) + wz) / 6.0);
119
- vec3 ex = pow(texture(materialParams_probeLight, fx).rgb, vec3(lg));
120
- vec3 ey = pow(texture(materialParams_probeLight, fy).rgb, vec3(lg));
121
- vec3 ez = pow(texture(materialParams_probeLight, fz).rgb, vec3(lg));
122
- vec3 w2 = ns * ns;
123
- baked = (w2.x * ex + w2.y * ey + w2.z * ez) * materialParams.lightScale;
124
- // the dominant direction within the normal's octant: each face's energy along its axis
125
- vec3 dirE = vec3(sign(ns.x) * dot(ex, luma), sign(ns.y) * dot(ey, luma), sign(ns.z) * dot(ez, luma));
126
- l = dot(dirE, dirE) > 1e-12 ? normalize(dirE) : ns;
127
- } else {
128
- vec2 luv = getUV1() * materialParams.lightmapST.xy + materialParams.lightmapST.zw;
129
- lm = texture(materialParams_lightmap, luv).rg;
130
- baked = pow(texture(materialParams_lightmapLight, luv).rgb, vec3(lg)) * materialParams.lightScale;
131
- l = ns; // the atlas keeps no direction: a static's lamp light is taken along its normal
132
- }
133
- lm.g = mix(1.0, lm.g, materialParams.bakedAo);
134
- material.ambientOcclusion = lm.g * occ;
135
-
136
- // Baked point lights on the pixel, exposed here (emissive.w = 0 adds it as-is) so the glTF
137
- // emissive can share the slot. Diffuse: albedo × (1 − metallic) × E / π — filament's own punctual
138
- // diffuse. Specular: the same E through a GGX lobe from `l`, so METALS take baked light too — their
139
- // diffuse is zero, and a rough steel table used to render darker than the floor it stands on because
140
- // only the ambient probe reached it. L_out = f_spec · E_⊥ · NoL, and E(n) = E_⊥ · NoL is what the
141
- // store holds. The lobe is kept no tighter than roughness 0.5: the stored E is light gathered from
142
- // a room, not one bulb, and a mirror-sharp lobe would paint it as a highlight over the whole surface.
143
- vec3 diffuseColor = material.baseColor.rgb * (1.0 - material.metallic);
144
- vec3 specBrdf;
145
- {
146
- vec3 v = getWorldViewVector();
147
- vec3 h = normalize(v + l);
148
- float NoL = saturate(dot(ns, l));
149
- float NoV = max(dot(ns, v), 1e-4);
150
- float NoH = saturate(dot(ns, h));
151
- float LoH = saturate(dot(l, h));
152
- float pr = max(material.roughness, 0.5);
153
- float a2 = pr * pr;
154
- a2 *= a2;
155
- float dd = NoH * NoH * (a2 - 1.0) + 1.0;
156
- float D = a2 / (PI * dd * dd);
157
- float V = 0.5 / max(NoL * sqrt(NoV * NoV * (1.0 - a2) + a2) + NoV * sqrt(NoL * NoL * (1.0 - a2) + a2), 1e-4);
158
- vec3 f0 = mix(vec3(0.04), material.baseColor.rgb, material.metallic);
159
- vec3 F = f0 + (1.0 - f0) * pow(1.0 - LoH, 5.0);
160
- specBrdf = D * V * F;
161
- }
162
- material.emissive = vec4(glow + (diffuseColor / PI + specBrdf) * baked * getExposure(), 0.0);
151
+ float metallic = materialParams.metallicFactor * mr.x;
152
+ float roughness = materialParams.roughnessFactor * mr.y;
153
+ // highp, and nits x exposure FIRST (the PRECISION note in the header): the camera's own reading of emission 1.0 is
154
+ // 1.2 x 2^EV100 = 39 000 nits outdoors - times a strength of 10 it is past a half's 65 504 before the exposure
155
+ highp vec3 glow = texture(materialParams_emissiveMap, getUV0()).rgb * (materialParams.emissiveFactor * materialParams.emissiveStrength);
156
+ if (materialParams.emissiveNits > 0.0) glow *= (materialParams.emissiveNits * getExposure());
163
157
 
164
- float vis = mix(1.0, lm.r, materialParams.sunStrength);
165
- // The ambient share, from the SAME per-frame uniforms filament shades this pixel with
166
- // (frameUniforms: the directional light + the IBL's SH, both exposure-premultiplied), so
167
- // the floor is exactly the IBL-only brightness filament's own shadow map would leave.
168
- vec3 sh = frameUniforms.iblSH[0].xyz
169
- + frameUniforms.iblSH[1].xyz * n.y + frameUniforms.iblSH[2].xyz * n.z + frameUniforms.iblSH[3].xyz * n.x
170
- + frameUniforms.iblSH[4].xyz * (n.y * n.x) + frameUniforms.iblSH[5].xyz * (n.y * n.z)
171
- + frameUniforms.iblSH[6].xyz * (3.0 * n.z * n.z - 1.0) + frameUniforms.iblSH[7].xyz * (n.z * n.x)
172
- + frameUniforms.iblSH[8].xyz * (n.x * n.x - n.y * n.y);
173
- // diffuse terms in filament's units: IBL = albedo * E_sh * iblLuminance; sun = albedo * (color * intensity) * NoL / PI
174
- float amb = max(dot(max(sh, 0.0), luma), 0.0) * frameUniforms.iblLuminance * materialParams.ambientScale * lm.g;
175
- float nol = max(dot(n, frameUniforms.lightDirection), 0.0);
176
- float sun = dot(frameUniforms.lightColorIntensity.rgb, luma) * frameUniforms.lightColorIntensity.w * nol / PI;
177
- // the post-lighting multiply also scales the emissive slot the baked lamps ride in, so their
178
- // share (same units: exposed irradiance / π) sits on both sides — a lamp in the sun's shadow
179
- // is not dimmed by it
180
- float lamp = dot(baked, luma) * (getExposure() / PI);
181
- float k = (amb + sun * vis + lamp) / max(amb + sun + lamp, 1e-6);
182
- material.postLightingColor = vec4(k, k, k, 1.0);
158
+ lightmapShade(material, n, ns, base, metallic, roughness, glow);
183
159
  }
184
160
  }
@@ -88,8 +88,12 @@ vertex {
88
88
  float3 x = normalize(cross(y, camZ));
89
89
  offset = x * corner.x * c0.x + y * corner.y * (c0.x + materialParams.stretch * speed * yl);
90
90
  } else {
91
- // ribbon: extrude sideways from the strip tangent; corner.x carries u along the trail
92
- float3 x = cross(aux, camZ);
91
+ // ribbon: extrude sideways across the strip; corner.x carries u along the trail.
92
+ // c0.y (the billboard's rotation, meaningless here) says which kind of aux this is:
93
+ // 0 — the strip TANGENT, crossed with the view so the strip faces the camera
94
+ // 1 — the width axis already resolved on the CPU (an oriented / blade-aligned strip,
95
+ // blended back toward the camera as it goes edge-on; it owes the view nothing)
96
+ float3 x = c0.y > 0.5 ? aux : cross(aux, camZ);
93
97
  float xl = length(x);
94
98
  x = xl > 1e-4 ? x / xl : camX;
95
99
  offset = x * corner.y * c0.x;
@@ -0,0 +1,123 @@
1
+ // terrain-lightmap — THE BAKED TERRAIN: terrain.mat's surface (four splat layers blended by the control map, see its
2
+ // header) shaded like lightmap.mat (its header is the model: the whole diffuse light is the baked irradiance atlas on
3
+ // UV1, no real-time sun, no diffuse IBL; the sun's share comes back out where a mover's real-time shadow falls). The
4
+ // shading is lightmap-shading.fs, the very code lightmap.mat runs — a terrain next to a baked wall cannot drift from it.
5
+ //
6
+ // UV1 is the terrain's global unit square (creator-gl/src/terrain.cpp): ONE atlas rect for the whole terrain, which
7
+ // lightmapApply addresses through `lightmapST` on the terrain's single material instance.
8
+ //
9
+ // WHO USES IT: a scene file's `terrain:` node that is a lightmap static (sdk scene/defineScene.ts) is created with this
10
+ // material instead of terrain.mat, like a model is loaded with lightmap.mat. Without a bound bake it is black, like
11
+ // any lightmapped static.
12
+ //
13
+ // NO REFLECTION PROBES (LIGHTMAP_NO_PROBES): feature level 1 gives a material 9 samplers and the layers take 7 of them,
14
+ // the atlas 2. Ground is rough and mostly under the sky: it reflects the sky's path (SH radiance x the baked sky
15
+ // visibility), which is what lightmap.mat does wherever no probe is seen.
16
+ material {
17
+ name : terrain_lightmap,
18
+ shadingModel : lit,
19
+ // geometric specular AA (Kaplanyan): the ground is a 1024² normal map at ×1.5–2 tiled every 2 m under a
20
+ // 100 000 lux sun — without this every minified texel is a sub-pixel mirror and the sand sparkles
21
+ // (Mountain, 2026-09-23; A/B with normalScale 0 showed nothing but the normal map does it)
22
+ specularAntiAliasing : true,
23
+ blending : ${BLENDING},
24
+ flipUV : false,
25
+ requires : [ uv0, uv1 ],
26
+ specularAmbientOcclusion : simple,
27
+ parameters : [
28
+ // ---- the surface: terrain.mat's
29
+ { type : sampler2d, name : control },
30
+ { type : sampler2d, name : albedo0 },
31
+ { type : sampler2d, name : albedo1 },
32
+ { type : sampler2d, name : albedo2 },
33
+ { type : sampler2d, name : albedo3 },
34
+ { type : sampler2d, name : normals01 },
35
+ { type : sampler2d, name : normals23 },
36
+ { type : float4, name : tiling },
37
+ { type : float4, name : roughness },
38
+ { type : float4, name : normalScale },
39
+ { type : float4, name : triplanar },
40
+ { type : float2, name : gridSize },
41
+ { type : float, name : cellSize },
42
+ { type : float3, name : tint },
43
+ // ---- the bake: lightmap.mat's (the comments are there), without the probe atlas
44
+ { type : sampler2d, name : lightmapLight },
45
+ { type : sampler2d, name : lightmapAux },
46
+ { type : float4, name : lightmapST, precision : high },
47
+ { type : float4, name : probeParams },
48
+ { type : float4, name : probeGrid, precision : high },
49
+ { type : float4, name : probeGridN, precision : high },
50
+ { type : float, name : probeBall },
51
+ { type : float4[6], name : objectCube, precision : high },
52
+ { type : float4, name : objectProbes, precision : high },
53
+ { type : float4, name : sunDir },
54
+ { type : float4, name : sunLight, precision : high },
55
+ { type : float, name : lightScale, precision : high },
56
+ { type : float, name : debugMode },
57
+ { type : float4, name : debugParams }
58
+ ]
59
+ }
60
+
61
+ fragment {
62
+ #define LIGHTMAP_NO_PROBES
63
+ #include "lightmap-shading.fs"
64
+
65
+ vec4 triplanarSample(sampler2D tex, highp vec3 p, vec3 bw, float s) {
66
+ return texture(tex, p.zy / s) * bw.x + texture(tex, p.xz / s) * bw.y + texture(tex, p.xy / s) * bw.z;
67
+ }
68
+
69
+ // `slot` 0 = this layer's normal XY sits in the pack's RG, 1 = in its BA
70
+ void splatLayer(sampler2D alb, sampler2D pack, float slot, float tiling, float nscale, float tri, float w,
71
+ highp vec2 uv, highp vec3 wp, vec3 bw, float rf, inout vec3 albedo, inout vec3 nrm, inout float rough) {
72
+ if (w <= 0.0) return;
73
+ float s = max(tiling, 1e-3);
74
+ if (tri > 0.5) {
75
+ vec4 c = triplanarSample(alb, wp, bw, s);
76
+ albedo += c.rgb * w;
77
+ rough += rf * c.a * w;
78
+ nrm += vec3(0.0, 0.0, 1.0) * w;
79
+ } else {
80
+ highp vec2 luv = uv / s;
81
+ vec4 c = texture(alb, luv);
82
+ albedo += c.rgb * w;
83
+ rough += rf * c.a * w;
84
+ if (nscale > 0.0) {
85
+ vec4 p = texture(pack, luv);
86
+ vec2 xy = (slot > 0.5 ? p.ba : p.rg) * 2.0 - 1.0;
87
+ xy *= nscale;
88
+ float z = sqrt(max(1.0 - dot(xy, xy), 0.0));
89
+ nrm += vec3(xy, z) * w;
90
+ } else {
91
+ nrm += vec3(0.0, 0.0, 1.0) * w;
92
+ }
93
+ }
94
+ }
95
+
96
+ void material(inout MaterialInputs material) {
97
+ // metres and world positions are highp (lightmap.mat's PRECISION note: a half steps by centimetres at 100 m)
98
+ highp vec2 uv = getUV0();
99
+ highp vec2 cuv = (uv / max(materialParams.cellSize, 1e-4) + 0.5) / max(materialParams.gridSize, vec2(1.0));
100
+ vec4 w = texture(materialParams_control, cuv);
101
+ float ws = w.r + w.g + w.b + w.a;
102
+ w = ws > 1e-4 ? w / ws : vec4(1.0, 0.0, 0.0, 0.0);
103
+
104
+ highp vec3 wp = getUserWorldPosition();
105
+ vec3 gn = getWorldGeometricNormalVector();
106
+ vec3 bw = abs(gn);
107
+ bw = bw / max(bw.x + bw.y + bw.z, 1e-4);
108
+
109
+ vec3 albedo = vec3(0.0);
110
+ vec3 nrm = vec3(0.0);
111
+ float rough = 0.0;
112
+ splatLayer(materialParams_albedo0, materialParams_normals01, 0.0, materialParams.tiling.x, materialParams.normalScale.x, materialParams.triplanar.x, w.r, uv, wp, bw, materialParams.roughness.x, albedo, nrm, rough);
113
+ splatLayer(materialParams_albedo1, materialParams_normals01, 1.0, materialParams.tiling.y, materialParams.normalScale.y, materialParams.triplanar.y, w.g, uv, wp, bw, materialParams.roughness.y, albedo, nrm, rough);
114
+ splatLayer(materialParams_albedo2, materialParams_normals23, 0.0, materialParams.tiling.z, materialParams.normalScale.z, materialParams.triplanar.z, w.b, uv, wp, bw, materialParams.roughness.z, albedo, nrm, rough);
115
+ splatLayer(materialParams_albedo3, materialParams_normals23, 1.0, materialParams.tiling.w, materialParams.normalScale.w, materialParams.triplanar.w, w.a, uv, wp, bw, materialParams.roughness.w, albedo, nrm, rough);
116
+ vec3 tn = normalize(nrm);
117
+ material.normal = tn;
118
+ vec3 ns = normalize(getWorldTangentFrame() * tn);
119
+ prepareMaterial(material);
120
+
121
+ lightmapShade(material, gn, ns, vec4(albedo * materialParams.tint, 1.0), 0.0, rough, vec3(0.0));
122
+ }
123
+ }