lecodes-sdk 2.4.9 → 2.5.0

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 (74) hide show
  1. package/dist/global.d.ts +35 -0
  2. package/dist/types/core/env.d.ts +25 -0
  3. package/dist/types/core/particlesConfig.d.ts +196 -0
  4. package/dist/types/g2/Font2D.d.ts +19 -0
  5. package/dist/types/g2/Material2D.d.ts +24 -0
  6. package/dist/types/g2/Node2D.d.ts +6 -5
  7. package/dist/types/g2/Particles2D.d.ts +169 -0
  8. package/dist/types/g2/Path2D.d.ts +95 -0
  9. package/dist/types/g2/Physics2D.d.ts +1 -1
  10. package/dist/types/g2/Scene2D.d.ts +15 -17
  11. package/dist/types/g2/Sprite.d.ts +12 -1
  12. package/dist/types/g2/SpriteSheet.d.ts +4 -1
  13. package/dist/types/g2/Text2D.d.ts +96 -0
  14. package/dist/types/g2/Tilemap.d.ts +8 -0
  15. package/dist/types/g2/defineScene2d.d.ts +69 -11
  16. package/dist/types/gl/Foliage.d.ts +0 -7
  17. package/dist/types/gl/Lightmap.d.ts +101 -2
  18. package/dist/types/gl/Material.d.ts +21 -15
  19. package/dist/types/gl/Model.d.ts +8 -3
  20. package/dist/types/gl/Particles.d.ts +3 -135
  21. package/dist/types/gl/Statics.d.ts +56 -0
  22. package/dist/types/gl/staticsFormat.d.ts +101 -0
  23. package/dist/types/inject.d.ts +11 -3
  24. package/dist/types/runtime/env.d.ts +10 -0
  25. package/dist/types/runtime/envValues.d.ts +8 -0
  26. package/dist/types/scene/defineScene.d.ts +17 -12
  27. package/dist/types/server/env.d.ts +14 -0
  28. package/dist/types/server/inject.d.ts +1 -0
  29. package/dist/types/ui/fonts.d.ts +4 -0
  30. package/dist/types/version.d.ts +1 -1
  31. package/dist/types.json +1 -1
  32. package/package.json +1 -1
  33. package/src/bridges/2d.d.ts +65 -5
  34. package/src/bridges/gl.d.ts +34 -15
  35. package/src/compile/bakeMacro.ts +3 -0
  36. package/src/compile/compileProject.ts +25 -4
  37. package/src/compile/envCompile.ts +74 -0
  38. package/src/compile/fontMacro.ts +11 -6
  39. package/src/compile/index.ts +6 -0
  40. package/src/compile/resourceSlots.ts +1 -0
  41. package/src/compile/serverSplit.ts +47 -4
  42. package/src/core/env.ts +47 -0
  43. package/src/core/particlesConfig.ts +422 -0
  44. package/src/g2/Font2D.ts +87 -0
  45. package/src/g2/Material2D.ts +64 -0
  46. package/src/g2/Node2D.ts +10 -6
  47. package/src/g2/Particles2D.ts +337 -0
  48. package/src/g2/Path2D.ts +215 -0
  49. package/src/g2/Physics2D.ts +1 -1
  50. package/src/g2/Scene2D.ts +16 -22
  51. package/src/g2/Sprite.ts +23 -4
  52. package/src/g2/SpriteSheet.ts +9 -5
  53. package/src/g2/Text2D.ts +178 -0
  54. package/src/g2/Tilemap.ts +10 -0
  55. package/src/g2/defineScene2d.ts +108 -21
  56. package/src/g2/groups2d.ts +2 -2
  57. package/src/gl/Foliage.ts +1 -16
  58. package/src/gl/Lightmap.ts +79 -26
  59. package/src/gl/Material.ts +51 -21
  60. package/src/gl/Model.ts +19 -5
  61. package/src/gl/Particles.ts +22 -361
  62. package/src/gl/Statics.ts +301 -0
  63. package/src/gl/nav/NavMesh.ts +4 -1
  64. package/src/gl/staticsFormat.ts +240 -0
  65. package/src/inject.ts +18 -3
  66. package/src/runtime/env.ts +13 -0
  67. package/src/runtime/envValues.ts +8 -0
  68. package/src/runtime/fetch.ts +18 -0
  69. package/src/scene/defineScene.ts +107 -27
  70. package/src/server/env.ts +25 -0
  71. package/src/server/host.ts +6 -0
  72. package/src/server/inject.ts +2 -1
  73. package/src/ui/fonts.ts +46 -0
  74. package/src/version.ts +1 -1
@@ -77,7 +77,7 @@ export type LightmapOptions = {
77
77
  * what is under it by the solid angle it fills, with no picture. Dense canvas: `{ through: 0.08, diffuse: 0.25 }`. */
78
78
  transmit?: Record<string, LightmapTransmit>
79
79
  /** FOR THE BAKE: CUBES BY NAME — regular expressions (ECMAScript, case-sensitive) searched in every record's key
80
- * (`<node key>@<ordinal>`: `"SM_pipe"` takes every pipe node, `"@1$"` the second mesh of each): a record that matches
80
+ * (`<node key>@<ordinal>`: `"SM_pipe"` takes every pipe node, `"@1$"` the second node primitive of each): a record that matches
81
81
  * leaves the atlas like a small or thin one — an occluder lit by ambient cubes baked where it stands — whatever its size
82
82
  * or charts. Added to the CLI's `--cube` patterns (both apply). */
83
83
  cube?: string[]
@@ -121,6 +121,9 @@ export type LightmapFiles = {
121
121
  /** THE PACKED LIGHTMAP UV1 (`<stem>-uv1.bin`): the bake packs every mesh's lightmap charts into a square of their own
122
122
  * and its rects map that uv1 — the engine swaps it into the models' TEXCOORD_1 before the rects go on. */
123
123
  uv1?: string
124
+ /** THE STATIC LEVEL CACHE (`<stem>.static`, gl/Statics.ts): the snapshot `lecodes static` wrote beside the bake; the macro
125
+ * stages it when it exists, and `load()` rebuilds the level's statics from it when its digest still matches. */
126
+ statics?: string
124
127
  }
125
128
 
126
129
  /** What `bake('<path>.bake', options)` compiles to — the files of the bake and the level's options in one literal:
@@ -142,6 +145,7 @@ export type LightmapDebugParams = {
142
145
 
143
146
  /** What `Lightmap.probe` answers: the baked texel under a screen point. */
144
147
  export type LightmapProbe = {
148
+ /** the bake's record (`<key>@<ordinal>`) whose rect holds the texel; "" when none does */
145
149
  key: string
146
150
  entity: number
147
151
  node: string
@@ -239,9 +243,9 @@ type BakeConfig = {
239
243
  * from `from` to `to` along it) — one for a small one, round(length / objectSlab) for a long one — and per slab per side
240
244
  * of its box (+x -x +y -y +z -z) the irradiance rgb in lux + the sky visibility; probes = a, b, a's weight (-1 = none,
241
245
  * -2 = the sky) */
242
- type BakeObjectLight = { at: number[], axis: number, from: number, to: number, cubes: number[][][], probes: number[] }
246
+ export type BakeObjectLight = { at: number[], axis: number, from: number, to: number, cubes: number[][][], probes: number[] }
243
247
  /** a RECORD of the .bake: a registered node's key, or `<key>@<ordinal>` for one mesh of a model (creator-bake atlas.h) */
244
- type BakeInstance = { key: string, receiver: boolean, small?: boolean, light?: BakeObjectLight, page?: number, rect?: number[], st?: number[] }
248
+ export type BakeInstance = { key: string, receiver: boolean, small?: boolean, light?: BakeObjectLight, page?: number, rect?: number[], st?: number[] }
245
249
  type BakeProbes = {
246
250
  file: string, size: number, levels: number, count: number, oct: number, tile: number[], perRow: number, atlas: number[],
247
251
  spacing: number, layout: "rooms" | "grid",
@@ -251,8 +255,8 @@ type BakeProbes = {
251
255
  /** per probe: its open share of the sphere (0 = a room, over 0.15 = under the sky) */
252
256
  sky: number[],
253
257
  }
254
- type BakeData = {
255
- version: number, size: number, pages: number, texel: number, lightScale: number,
258
+ export type BakeData = {
259
+ version: number, size: number, pages: number, texel: number, lightScale: number, texels?: number,
256
260
  light: string[], aux: string[], direct?: string[], indirect?: string[],
257
261
  probes?: BakeProbes | null,
258
262
  /** the light grid (creator-bake volume.h): its file and lattice */
@@ -263,6 +267,8 @@ type BakeData = {
263
267
  instances: BakeInstance[],
264
268
  }
265
269
  type PageKind = "light" | "direct" | "indirect"
270
+ /** What `Lightmap.prepare` hands `apply`: the bake read and its pages loaded. */
271
+ export type LightmapPrepared = { def: LightmapDef, data: BakeData, n: number, light: Texture[], aux: Texture[], probes: number, probeAtlas?: Texture, t: number[] }
266
272
  type Bound = {
267
273
  data: BakeData, rects: Map<string, BakeInstance>, byEntity: Map<number, Entry>, files: LightmapFiles, pages: number,
268
274
  aux: Texture[], light: Texture[], direct?: Texture[], indirect?: Texture[],
@@ -434,9 +440,21 @@ export class Lightmap {
434
440
  * to (a scene file's `env.lightmap`). Resolves to null when nothing was applied (no bake yet, a host without the
435
441
  * feature, bake mode). */
436
442
  private static scene: Scene | null = null
437
- static async load(scene: Scene, def: LightmapDef): Promise<LightmapInfo | null> {
443
+ /** `load` in two halves (the static level cache binds records as it clones, so the pages must be in BEFORE the
444
+ * statics build): `prepare` reads the bake, swaps the uv1 in, loads the pages / probes and sets the level-wide
445
+ * options; `apply` binds the rects of the statics the SDK registered, then the light grid. */
446
+ static async prepare(scene: Scene, def: LightmapDef): Promise<LightmapPrepared | null> {
438
447
  Lightmap.scene = scene
439
448
  const bake = bakeConfig()
449
+ // a def built by hand the old way — `{ data, light: asset('…-light.ktx2'), aux }`: the pages are LISTS and `path` the
450
+ // bake's target (only the bake needs it), both of them bake()'s; without this a string page fails as "not a function"
451
+ // on its .map, a missing path on its .lastIndexOf
452
+ if (!Array.isArray(def.light) || !Array.isArray(def.aux) || (bake && typeof def.path !== "string")) {
453
+ const msg = `${String(def.path ?? def.data)} is not what bake() compiles to — pass \`bake('<path>.bake')\`: it reads every page off the manifest`
454
+ if (bake) console.error(`[lightmap] error: ${msg}`)
455
+ else console.warn(`Lightmap: ${msg}; rendering without the bake`)
456
+ return null
457
+ }
440
458
  if (bake) { Lightmap.bake(bake, def); return null }
441
459
  if (!_creator.lightmapApply) {
442
460
  if (!Lightmap.warned) { Lightmap.warned = true; console.warn("Lightmap: this host has no lightmap support — rendering without the bake") }
@@ -444,6 +462,7 @@ export class Lightmap {
444
462
  }
445
463
  if (def.data === null) { console.warn(`Lightmap: no bake at ${def.path} yet — run \`lecodes lightmap bake\``); return null }
446
464
  let data: BakeData
465
+ const tJson = Date.now()
447
466
  try {
448
467
  const resp = await fetch(def.data, { useOnce: true })
449
468
  if (resp.status >= 400) { console.warn(`Lightmap: no bake at ${def.path} (HTTP ${resp.status}) — run \`lecodes lightmap bake\``); return null }
@@ -456,7 +475,9 @@ export class Lightmap {
456
475
  console.warn(`Lightmap: ${def.path} is not a creator-bake file (version ${String(data.version)}) — rebake with \`lecodes lightmap bake\``)
457
476
  return null
458
477
  }
478
+ const tUv1 = Date.now()
459
479
  if (!(await Lightmap.loadUv1(data, def))) return null // the rects map the packed uv1: it goes in first
480
+ const tPages = Date.now()
460
481
  // the pages the manifest names, in order (bake() listed them off it); both atlases are DATA and exempt from
461
482
  // Texture.maxSize (a capped atlas blurs every shadow edge)
462
483
  const n = Math.min(data.pages, def.light.length, def.aux.length)
@@ -485,6 +506,24 @@ export class Lightmap {
485
506
  }
486
507
  }
487
508
  console.log(`[lightmap] bound ${n} page(s) of ${data.size} at ${(data.texel * 100).toFixed(2)} cm (${data.mips ?? 1} level(s), sun map ${data.sunMapLevels ?? (data.sunMap ? 1 : 0)}), lightScale ${lightScale}${(def.lightScale ?? 1) !== 1 ? ` (bake ${data.lightScale} x ${def.lightScale})` : ""}${probes > 0 ? `, ${probes} reflection probe(s)` : ""}`)
509
+ return { def, data, n, light, aux, probes, probeAtlas, t: [ tJson, tUv1, tPages, Date.now() ] }
510
+ }
511
+
512
+ /** The bake's page texture ids, light / aux interleaved per page — what the static level cache binds with. */
513
+ static pageIds(p: LightmapPrepared): Uint32Array {
514
+ const out = new Uint32Array(p.n * 2)
515
+ for (let i = 0; i < p.n; i++) { out[i * 2] = p.light[i]._h.id; out[i * 2 + 1] = p.aux[i]._h.id }
516
+ return out
517
+ }
518
+
519
+ /** @internal The bake the level is bound to (after `apply`), for the static level cache's snapshot. */
520
+ static _bakeData(): BakeData | null { return Lightmap.bound?.data ?? null }
521
+
522
+ static async apply(p: LightmapPrepared): Promise<LightmapInfo | null> {
523
+ if (!_creator.lightmapApply) return null // prepare checked; the narrowing is per function
524
+ const { def, data, n, light, aux, probes, probeAtlas } = p
525
+ const [ tJson, tUv1, tPages, tPagesDone ] = p.t
526
+ const tApply = Date.now()
488
527
  const rects = new Map<string, BakeInstance>()
489
528
  for (const inst of data.instances) if (inst.receiver && inst.st && inst.page !== undefined) rects.set(inst.key, inst)
490
529
  // the ones the bake left out for their size: occluders there, movers here — not a rect gone missing
@@ -499,8 +538,9 @@ export class Lightmap {
499
538
  // a bake from before the slabs (`light.cube`, one cube): not read — its small statics stay unlit until it is rebaked
500
539
  const lit = (l?: BakeObjectLight): l is BakeObjectLight => !!l && Array.isArray(l.cubes)
501
540
  if (data.instances.some((i) => i.small && i.light && !lit(i.light))) console.warn(`Lightmap: ${def.path} has small statics without \`light.cubes\` (baked before the slabbed object light) — they stay unlit; rebake with \`lecodes lightmap bake\``)
502
- // THE BAKE'S UNIT IS THE MESH: a model's primitives are records of their own, `<key>@<ordinal>` (ordinal = the primitive's
503
- // index over the file's meshes) - a rect, or a small static's light, each; the host binds record k to that primitive
541
+ // THE BAKE'S UNIT IS THE NODE'S MESH: a model's node primitives are records of their own, `<key>@<ordinal>` (ordinal = the
542
+ // primitive's index over the file's walked nodes) - a rect, or a small static's light, each; the host binds record k to
543
+ // that node's primitive (and its LOD twins')
504
544
  const parts = Lightmap.recordsOf(data.instances)
505
545
  let smallLit = 0
506
546
  const byEntity = new Map<number, Entry>()
@@ -547,10 +587,18 @@ export class Lightmap {
547
587
  if (small.size) console.log(`[lightmap] ${small.size} small, thin or cube-marked record(s) are not in the atlas (the bake's minTexels / thinTexels / cube): ${smallLit} lit by the light baked where they stand`)
548
588
  if (missing.length) console.warn(`Lightmap: ${missing.length} static(s) or mesh record(s) have no rect in the bake (${missing.slice(0, 5).join(", ")}${missing.length > 5 ? "…" : ""}) — rebake`)
549
589
  Lightmap.bound = { data, rects, byEntity, files: def, pages: n, aux, light, probes, probeAtlas, showing: "light" }
590
+ const tVolume = Date.now()
550
591
  await Lightmap.loadVolume(data, def)
592
+ console.log(`[lightmap] load: json ${tUv1 - tJson}, uv1 ${tPages - tUv1}, pages ${tPagesDone - tPages}, apply ${tVolume - tApply} (${applied + smallLit} of ${Lightmap.entries.length} statics, ${data.instances.length} records), volume ${Date.now() - tVolume} ms`)
551
593
  return { size: data.size, texel: data.texel, pages: n, applied, total: rects.size, missing }
552
594
  }
553
595
 
596
+ /** Bind a bake to the level: the statics registered so far take their rects, the movers the light grid. */
597
+ static async load(scene: Scene, def: LightmapDef): Promise<LightmapInfo | null> {
598
+ const p = await Lightmap.prepare(scene, def)
599
+ return p ? Lightmap.apply(p) : null
600
+ }
601
+
554
602
  /** The bake's `<key>@k` records by their node key: a model's meshes (k = the primitive's ordinal in its file). */
555
603
  private static recordsOf(instances: BakeInstance[]): Map<string, Map<number, BakeInstance>> {
556
604
  const out = new Map<string, Map<number, BakeInstance>>()
@@ -636,31 +684,36 @@ export class Lightmap {
636
684
  /** The view names in `lightmapDebug`'s order — for a knob that cycles them. */
637
685
  static get debugModes(): readonly LightmapDebugMode[] { return DEBUG_MODES }
638
686
 
639
- /** The baked texel under a screen point (logical px): its instance key, page and texel column / row,
687
+ /** The baked texel under a screen point (logical px): its record key, page and texel column / row,
640
688
  * so the value can be looked up in the bake's debug layers (`lecodes lightmap inspect`). null when
641
689
  * nothing baked is under the point or the host has no triangle pick. */
642
690
  static probe(screenX: number, screenY: number): LightmapProbe | null {
643
691
  const b = Lightmap.bound
644
692
  if (!b || !_creator.pickTriangle) return null
645
- type Hit = { entity: number, nodeName: string, material: string, point: number[], distance: number, uv1?: [number, number], lightmapOrdinal?: number }
646
- // the nearest hit over the registered statics: one pick per entry (the all-instances pick, entity 0, walks
647
- // every GLB instance including the off-scene templates and is what a scene file's clones confuse)
648
- let hit: Hit | null = null
649
- for (const id of b.byEntity.keys()) {
650
- const h: Hit | null = _creator.pickTriangle(id, screenX, screenY)
651
- if (h && (!hit || h.distance < hit.distance)) hit = h
652
- }
653
- if (!hit) { console.log(`[probe] nothing baked under ${screenX.toFixed(0)},${screenY.toFixed(0)}`); return null }
654
- const entry = b.byEntity.get(hit.entity)
655
- if (!entry) { console.log(`[probe] hit entity ${hit.entity} node "${hit.nodeName}" at ${hit.point.map((c) => c.toFixed(2)).join(",")} is not a registered static`); return null }
656
- const hitKey = hit.lightmapOrdinal !== undefined && b.rects.has(`${entry.key}@${hit.lightmapOrdinal}`) ? `${entry.key}@${hit.lightmapOrdinal}` : entry.key
657
- const inst = b.rects.get(hitKey)
658
- if (!inst || !inst.st) { console.log(`[probe] ${hitKey}: no rect in the bake`); return null }
659
- if (!hit.uv1) { console.log(`[probe] ${entry.key}: the hit primitive carries no TEXCOORD_1`); return null }
660
- const st = inst.st
693
+ // ONE PICK OVER THE SCENE (entity 0: the engine skips what is out of the scene or not drawn - a template, a LOD level
694
+ // not shown) and THE RECT FROM THE ENGINE: under the static level cache the statics have no JS node and no registry
695
+ // entry, the host bound their rects itself, so the hit's own material answers (lightmapTexture + lightmapST) and the
696
+ // record is the receiver whose rect on that page holds the point (rects never overlap: atlas.h)
697
+ const hit = _creator.pickTriangle(0, screenX, screenY)
698
+ if (!hit) { console.log(`[probe] nothing under ${screenX.toFixed(0)},${screenY.toFixed(0)}`); return null }
699
+ const where = `node "${hit.nodeName}" at ${hit.point.map((c) => c.toFixed(2)).join(",")}`
700
+ if (!hit.uv1) { console.log(`[probe] ${where}: the hit primitive carries no TEXCOORD_1`); return null }
701
+ if (!hit.lightmapST || !hit.lightmapTexture) { console.log(`[probe] ${where}: draws with no rect of the bake (a mover, or a static the bake left out)`); return null }
702
+ const sets: Texture[][] = [ b.light, b.direct ?? [], b.indirect ?? [] ]
703
+ let page = -1
704
+ for (const set of sets) { page = set.findIndex((t) => t._h.id === hit.lightmapTexture); if (page >= 0) break }
705
+ if (page < 0) { console.log(`[probe] ${where}: its light page (texture ${hit.lightmapTexture}) is not of the bound bake`); return null }
706
+ const st = hit.lightmapST
661
707
  const u = hit.uv1[0] * st[0] + st[2], v = hit.uv1[1] * st[1] + st[3]
708
+ let key = ""
709
+ for (const inst of b.data.instances) {
710
+ const r = inst.st
711
+ if (!inst.receiver || inst.page !== page || !r) continue
712
+ if (u >= r[2] && u <= r[2] + r[0] && v >= r[3] && v <= r[3] + r[1]) { key = inst.key; break }
713
+ }
714
+ if (key === "") console.log(`[probe] ${where}: no record of the bake holds page ${page} texel ${Math.floor(u * b.data.size)},${Math.floor(v * b.data.size)} (a rebind?)`)
662
715
  return {
663
- key: hitKey, entity: hit.entity, node: hit.nodeName, material: hit.material, page: inst.page ?? 0,
716
+ key, entity: hit.entity, node: hit.nodeName, material: hit.material, page,
664
717
  texel: [ Math.floor(u * b.data.size), Math.floor(v * b.data.size) ],
665
718
  uv1: [ hit.uv1[0], hit.uv1[1] ], world: [ hit.point[0], hit.point[1], hit.point[2] ], distance: hit.distance,
666
719
  }
@@ -450,24 +450,48 @@ export class Material {
450
450
  * materials (foliage cards) take it instead of falling back to the ubershader's full lit path. */
451
451
  static _lightmapMaskedTemplate(): Material { return (Material._lmMaskedTemplate ??= Material.lightmapMasked()) }
452
452
  private static _lmMaskedTemplate: Material | undefined
453
- /** The FOLIAGE tier (`Model.load(…, { foliage: true })`, see `Foliage`): an unlit shader with its own cheap
454
- * lighting plus a vertex shader that sways in the wind, bends away from benders and thins out with distance,
455
- * and a per-copy tint. glTF parameters like `Material.lightmap()` plus `wind` / `sway` / `fade` / `benders`,
456
- * which the engine writes itself. Not baked by the rewritten bake yet. */
453
+ /** The FOLIAGE tier's BARK (`Model.load(…, { foliage })`, see `Foliage`): the opaque material a vegetation model's
454
+ * trunks, limbs and stems take — lit by the sky and the real-time sun, swaying in the wind, bending away from
455
+ * benders, thinning out with distance, with a per-copy tint. glTF parameters like `Material.lightmap()` plus
456
+ * `wind` / `sway` / `fade` / `benders`, which the engine writes itself. */
457
457
  static foliage(): Material {
458
458
  const m = Material._builtin(_creator.builtinMaterial("foliage"), "foliage")
459
459
  Material._glbDefaults(m)
460
460
  return m
461
461
  }
462
462
 
463
- /** `Material.foliage()`'s masked twin (`blending: masked`): what the engine gives a foliage model's alpha-MASK
464
- * materials — the cards. */
465
- static foliageMasked(): Material {
466
- const m = Material._builtin(_creator.builtinMaterial("foliage-masked"), "foliage-masked")
463
+ /** The CARDS of a crown (`Model.load(…, { foliage: 'tree' })`): what a vegetation model's alpha-MASK materials take
464
+ * — leaves and needles lit per vertex on the crown's hull normal by the sky and the real-time sun, with the
465
+ * sun's shadow per pixel. Ground cover takes `Material.grass()` instead. */
466
+ static foliageCards(): Material {
467
+ const m = Material._builtin(_creator.builtinMaterial("foliage-cards"), "foliage-cards")
467
468
  Material._glbDefaults(m)
468
469
  return m
469
470
  }
470
471
 
472
+ /** GROUND COVER (`Model.load(…, { foliage: 'grass' })`): the cards of grass, ferns, heath — the plant that IS the
473
+ * ground's surface. The same wind as `Material.foliage()`, lit by the terrain it stands on: the ground's baked
474
+ * ambient and sun share under it, the soil's colour at its base. A crown (`'tree'`) reads nothing from the ground. */
475
+ static grass(): Material {
476
+ const m = Material._builtin(_creator.builtinMaterial("grass"), "grass")
477
+ Material._glbDefaults(m)
478
+ return m
479
+ }
480
+
481
+ /** A tree's far IMPOSTOR (`Model.load(…, { foliage: 'tree' })`): the material of the camera-facing quad
482
+ * lecodes-assets bakes into a tree's GLB as its last LOD level — an atlas of views of the tree (a grid of azimuths ×
483
+ * elevation rows, or a hemi-octahedron; albedo, normal, baked sky visibility) lit per pixel like the crown's cards.
484
+ * The engine picks it for the material whose extras carry `lecodes.impostor` and writes `impostorGrid` /
485
+ * `impostor` from them. */
486
+ static foliageImpostor(): Material {
487
+ const m = Material._builtin(_creator.builtinMaterial("foliage-impostor"), "foliage-impostor")
488
+ Material._glbDefaults(m)
489
+ // the quad shadows itself from its own atlas (the frame nearest the sun); matc has no parameter defaults, and the
490
+ // GLB path (glb.cpp impostorFromExtras) writes the same 1 — 0 turns the term off
491
+ m.set("impostorSelfShadow", 1)
492
+ return m
493
+ }
494
+
471
495
  /** the glTF-side identity values every provider-handed material starts from */
472
496
  private static _glbDefaults(m: Material): void {
473
497
  m.set("baseColorFactor", "#ffffffff").set("roughnessFactor", 1).set("metallicFactor", 0).set("hasNormalMap", 0)
@@ -485,20 +509,26 @@ export class Material {
485
509
  return Material._folTemplate
486
510
  }
487
511
  private static _folTemplate: Material | null | undefined
488
- /** Which material a foliage model's CARDS (its alpha-MASK materials) take: `"full"` = `foliage-masked`, the
489
- * shader with the card's own plane, normal map, edge fade and the ground's baked light; `"lean"` =
490
- * `foliage-cards`, the same wind with the lighting moved to the vertex (one texture read, the sun's shadow
491
- * kept) — a third of a forest frame less on a phone-class GPU (2026-10-18). Read when the first foliage
492
- * model loads; set it before. */
493
- static foliageCards: "full" | "lean" = "full"
494
- static _foliageMaskedTemplate(): Material | null {
495
- if (Material._folMaskedTemplate === undefined) {
496
- const lean = Material.foliageCards === "lean" ? _creator.builtinMaterial("foliage-cards") : null
497
- Material._folMaskedTemplate = Material._glbTemplate(lean ?? _creator.builtinMaterial("foliage-masked"))
498
- }
499
- return Material._folMaskedTemplate
512
+ /** @internal a crown's cards (foliage-cards.mat), the masked twin handed with the bark */
513
+ static _foliageCardsTemplate(): Material | null {
514
+ if (Material._folCardsTemplate === undefined) Material._folCardsTemplate = Material._glbTemplate(_creator.builtinMaterial("foliage-cards"))
515
+ return Material._folCardsTemplate
516
+ }
517
+ private static _folCardsTemplate: Material | null | undefined
518
+ /** @internal the ground cover's masked twin (grass.mat); null where the host carries no grass material — the
519
+ * crown's cards then (an older host) */
520
+ static _grassTemplate(): Material | null {
521
+ if (Material._grassTpl === undefined) Material._grassTpl = Material._glbTemplate(_creator.builtinMaterial("grass")) ?? Material._foliageCardsTemplate()
522
+ return Material._grassTpl
523
+ }
524
+ private static _grassTpl: Material | null | undefined
525
+ /** @internal a tree's far impostor (foliage-impostor.mat), handed with the bark and the cards; null where the host
526
+ * carries no such material — `Model.load` then arms the pair alone */
527
+ static _foliageImpostorTemplate(): Material | null {
528
+ if (Material._folImpostorTpl === undefined) Material._folImpostorTpl = Material._glbTemplate(_creator.builtinMaterial("foliage-impostor"))
529
+ return Material._folImpostorTpl
500
530
  }
501
- private static _folMaskedTemplate: Material | null | undefined
531
+ private static _folImpostorTpl: Material | null | undefined
502
532
  private static _glbTemplate(h: Handle | null): Material | null {
503
533
  if (!h) return null
504
534
  const m = new Material(h)
package/src/gl/Model.ts CHANGED
@@ -20,7 +20,7 @@ export interface TrianglePick {
20
20
  mesh: string
21
21
  primitive: number
22
22
  material: string
23
- /** the primitive's index over its file's meshes: the lightmap bake's record `<key>@<ordinal>` */
23
+ /** the primitive's index over its file's walked nodes' primitives: the lightmap bake's record `<key>@<ordinal>` */
24
24
  lightmapOrdinal: number
25
25
  /** triangle index within the primitive (index-buffer order), and whether the ray came from behind */
26
26
  triangle: number
@@ -34,6 +34,12 @@ export interface TrianglePick {
34
34
  vertices: { index: number, bind: [number, number, number], world: [number, number, number], bones: { name: string, weight: number }[] }[]
35
35
  }
36
36
 
37
+ /** What a vegetation model is lit as (`Model.load`'s `foliage`): a CROWN or GROUND COVER; false = not foliage. */
38
+ export type FoliageKind = false | "tree" | "grass"
39
+ /** @internal a scene node's `foliage` field as the kind `Model.load` takes (`true` = a crown) */
40
+ export const foliageKindOf = (f: boolean | "tree" | "grass" | { kind?: "tree" | "grass" } | undefined): FoliageKind =>
41
+ (!f ? false : typeof f === "object" ? f.kind ?? "tree" : f === "grass" ? "grass" : "tree")
42
+
37
43
  export class Model extends Node {
38
44
  /** The model's Animator — always present, its clip table = the GLB's embedded clips. Add to it
39
45
  * with `model.anim.addClip(name, clip)` (external clips), `model.anim.addLayer()`; set its fields
@@ -135,8 +141,11 @@ export class Model extends Node {
135
141
  * the standard shader, lit real-time (movers). Hosts without lightmap support ignore it. */
136
142
  lightmap?: boolean
137
143
  /** Vegetation: load through the FOLIAGE tier (see `Foliage`) — wind, touch bending, distance fade,
138
- * per-copy tint. Hosts without the tier fall back to the standard shader. */
139
- foliage?: boolean
144
+ * per-copy tint. `'tree'` (= `true`): a CROWN — a volume in the air, lit by the sky and the real-time sun
145
+ * through the shadow map. `'grass'`: GROUND COVER — grass, ferns, heath: the plant that is the ground's
146
+ * surface, lit by the terrain's baked light under it and tinted by its soil at the base. Hosts without the
147
+ * tier fall back to the standard shader. */
148
+ foliage?: boolean | "tree" | "grass"
140
149
  } = {},
141
150
  ): Promise<Model> {
142
151
  if (typeof source === "string") {
@@ -152,9 +161,14 @@ export class Model extends Node {
152
161
  let foliage = false
153
162
  if (foliageWanted) {
154
163
  const fol = Material._foliageTemplate()
155
- const folMasked = fol && Material._foliageMaskedTemplate()
164
+ const folMasked = fol && (options.foliage === "grass" ? Material._grassTemplate() : Material._foliageCardsTemplate())
156
165
  foliage = !!fol && !!folMasked
157
- if (fol && folMasked) _creator.setNextGlbLightmapped!(Material.idOf(fol), Material.idOf(folMasked))
166
+ // a tree also arms its far impostor (the `_LOD<k>` quad lecodes-assets bakes), when the host carries the material
167
+ const impostor = fol && folMasked && options.foliage !== "grass" ? Material._foliageImpostorTemplate() : null
168
+ if (fol && folMasked) {
169
+ if (impostor) _creator.setNextGlbLightmapped!(Material.idOf(fol), Material.idOf(folMasked), Material.idOf(impostor))
170
+ else _creator.setNextGlbLightmapped!(Material.idOf(fol), Material.idOf(folMasked))
171
+ }
158
172
  }
159
173
  const lightmapped = !foliage && !!options.lightmap && !!_creator.setNextGlbLightmapped
160
174
  if (lightmapped) _creator.setNextGlbLightmapped!(Material.idOf(Material._lightmapTemplate()), Material.idOf(Material._lightmapMaskedTemplate()))