@aardworx/wombat.rendering 0.9.3 → 0.9.5

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/core/buffer.d.ts.map +1 -1
  2. package/dist/core/buffer.js +65 -5
  3. package/dist/core/buffer.js.map +1 -1
  4. package/dist/core/index.d.ts +2 -0
  5. package/dist/core/index.d.ts.map +1 -1
  6. package/dist/core/index.js +1 -0
  7. package/dist/core/index.js.map +1 -1
  8. package/dist/core/provider.d.ts +38 -0
  9. package/dist/core/provider.d.ts.map +1 -0
  10. package/dist/core/provider.js +104 -0
  11. package/dist/core/provider.js.map +1 -0
  12. package/dist/core/renderObject.d.ts +17 -10
  13. package/dist/core/renderObject.d.ts.map +1 -1
  14. package/dist/core/sampler.d.ts.map +1 -1
  15. package/dist/core/sampler.js +71 -2
  16. package/dist/core/sampler.js.map +1 -1
  17. package/dist/core/texture.d.ts +9 -0
  18. package/dist/core/texture.d.ts.map +1 -1
  19. package/dist/core/texture.js +0 -0
  20. package/dist/core/texture.js.map +1 -1
  21. package/dist/resources/adaptiveTexture.d.ts.map +1 -1
  22. package/dist/resources/adaptiveTexture.js +7 -0
  23. package/dist/resources/adaptiveTexture.js.map +1 -1
  24. package/dist/resources/preparedRenderObject.d.ts.map +1 -1
  25. package/dist/resources/preparedRenderObject.js +20 -18
  26. package/dist/resources/preparedRenderObject.js.map +1 -1
  27. package/dist/resources/uniformBuffer.d.ts.map +1 -1
  28. package/dist/resources/uniformBuffer.js +23 -0
  29. package/dist/resources/uniformBuffer.js.map +1 -1
  30. package/dist/runtime/derivedUniforms/slots.d.ts.map +1 -1
  31. package/dist/runtime/derivedUniforms/slots.js +4 -0
  32. package/dist/runtime/derivedUniforms/slots.js.map +1 -1
  33. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  34. package/dist/runtime/heapAdapter.js +72 -29
  35. package/dist/runtime/heapAdapter.js.map +1 -1
  36. package/dist/runtime/heapDecoder.d.ts +52 -0
  37. package/dist/runtime/heapDecoder.d.ts.map +1 -0
  38. package/dist/runtime/heapDecoder.js +363 -0
  39. package/dist/runtime/heapDecoder.js.map +1 -0
  40. package/dist/runtime/heapEffectIR.d.ts.map +1 -1
  41. package/dist/runtime/heapEffectIR.js +300 -3
  42. package/dist/runtime/heapEffectIR.js.map +1 -1
  43. package/dist/runtime/heapEligibility.d.ts.map +1 -1
  44. package/dist/runtime/heapEligibility.js +42 -5
  45. package/dist/runtime/heapEligibility.js.map +1 -1
  46. package/dist/runtime/heapIrBuilders.d.ts +64 -0
  47. package/dist/runtime/heapIrBuilders.d.ts.map +1 -0
  48. package/dist/runtime/heapIrBuilders.js +311 -0
  49. package/dist/runtime/heapIrBuilders.js.map +1 -0
  50. package/dist/runtime/heapScene.d.ts.map +1 -1
  51. package/dist/runtime/heapScene.js +286 -97
  52. package/dist/runtime/heapScene.js.map +1 -1
  53. package/dist/runtime/textureAtlas/atlasPool.d.ts +67 -4
  54. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
  55. package/dist/runtime/textureAtlas/atlasPool.js +152 -18
  56. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
  57. package/package.json +4 -3
  58. package/src/core/buffer.ts +58 -6
  59. package/src/core/index.ts +11 -0
  60. package/src/core/provider.ts +143 -0
  61. package/src/core/renderObject.ts +17 -10
  62. package/src/core/sampler.ts +64 -2
  63. package/src/core/texture.ts +0 -0
  64. package/src/resources/adaptiveTexture.ts +7 -0
  65. package/src/resources/preparedRenderObject.ts +21 -16
  66. package/src/resources/uniformBuffer.ts +21 -0
  67. package/src/runtime/derivedUniforms/slots.ts +4 -0
  68. package/src/runtime/heapAdapter.ts +63 -29
  69. package/src/runtime/heapDecoder.ts +446 -0
  70. package/src/runtime/heapEffectIR.ts +314 -3
  71. package/src/runtime/heapEligibility.ts +34 -5
  72. package/src/runtime/heapIrBuilders.ts +365 -0
  73. package/src/runtime/heapScene.ts +295 -81
  74. package/src/runtime/textureAtlas/atlasPool.ts +152 -16
@@ -19,7 +19,8 @@
19
19
 
20
20
  import { compileModule, stage as makeStage, effect as makeEffect } from "@aardworx/wombat.shader";
21
21
  import type { Effect, CompileOptions } from "@aardworx/wombat.shader";
22
- import { substituteInputsInStage, readInputs, mapExpr, mapStmt, liftReturns } from "@aardworx/wombat.shader/passes";
22
+ import { substituteInputsInStage, readInputs, mapExpr, mapStmt, liftReturns, uniformsToInputs } from "@aardworx/wombat.shader/passes";
23
+ import { synthesizeHeapDecoderModule } from "./heapDecoder.js";
23
24
  import {
24
25
  type Module, type Expr, type Stmt, type Type, type ValueDef,
25
26
  type EntryDef, type EntryParameter, type ParamDecoration,
@@ -804,8 +805,17 @@ export function compileHeapEffectIR(
804
805
  compileOptions: CompileOptions,
805
806
  mode: HeapEffectEmitMode = "standalone",
806
807
  ): { vs: string; fs: string; preludeWgsl: string; vsEntry: string; fsEntry: string } {
807
- // VS-side input/uniform substitution: pure read-rewrites, expressed
808
- // via the Effect.substitute API.
808
+ // Both modes run through the composition-based heap rewrite.
809
+ // Family-member mode skips the megacall prelude in the decoder
810
+ // (wrapper VS owns it) and re-shapes the emitted @vertex into a
811
+ // regular function so heapShaderFamily can splice it into the family
812
+ // wrapper.
813
+ return compileHeapEffectIRViaDecoder(userEffect, layout, compileOptions, mode);
814
+
815
+ // ────────── legacy path (no longer reached) ──────────
816
+ // Kept below temporarily for reference during the migration. The
817
+ // unreachable code is dead-code-eliminated by the TS compiler.
818
+ // eslint-disable-next-line no-unreachable
809
819
  const heapShaped = userEffect.substitute({
810
820
  vertex: {
811
821
  inputs: buildVertexAttributeMap(layout),
@@ -880,6 +890,307 @@ export function compileHeapEffectIR(
880
890
  };
881
891
  }
882
892
 
893
+ /**
894
+ * Composition-based heap rewrite (standalone mode).
895
+ *
896
+ * Pipeline:
897
+ * 1. Merge user effect stages into one Module.
898
+ * 2. `uniformsToInputs` — every per-RO uniform name from the bucket
899
+ * layout gets renamed from Uniform scope to Input scope (so the
900
+ * decoder VS writes it into the carrier, and the rename is visible
901
+ * to BOTH the VS and the FS sides simultaneously). The pass also
902
+ * surfaces matching `EntryParameter` declarations on every entry
903
+ * whose body reads them — required so `extractFusedEntry`'s State-
904
+ * struct construction sees them as ports.
905
+ * 3. Synthesise the heap decoder VS Module from the layout
906
+ * (`synthesizeHeapDecoderModule`). The decoder owns the megacall
907
+ * search and writes every drawHeader field — per-vertex
908
+ * attributes, per-RO uniforms, per-instance variants, and atlas
909
+ * texture sub-fields — onto the inter-stage carrier.
910
+ * 4. Concatenate values: `[heapDecls, decoderEntry, ...userModule.values]`.
911
+ * `composeStages` (run by `compileModule`) fuses the decoder VS
912
+ * with the user VS via the standard State-struct path.
913
+ * 5. `rewriteFsAtlasTexturesViaCarrier` — FS-side substitution only:
914
+ * every `textureSample(t, smp, uv)` for an atlas-routed binding
915
+ * becomes `atlasSample(Input._h_<t>PageRef, …, uv)`. The decoder
916
+ * already wrote those carrier slots in step 3 so no VS-side writes
917
+ * are needed.
918
+ * 6. `liftReturns` + `injectVsBuiltins` + `compileModule`.
919
+ *
920
+ * No post-emit string mangling — `applyMegacallToEmittedVs` /
921
+ * `applyFamilyMemberShape` are gone for this path.
922
+ */
923
+ function compileHeapEffectIRViaDecoder(
924
+ userEffect: Effect,
925
+ layout: BucketLayout,
926
+ compileOptions: CompileOptions,
927
+ mode: HeapEffectEmitMode = "standalone",
928
+ ): { vs: string; fs: string; preludeWgsl: string; vsEntry: string; fsEntry: string } {
929
+ // 1. Merge user stages.
930
+ let userModule: Module = mergeStages(userEffect);
931
+
932
+ // 2. Rename per-RO uniforms (Uniform → Input). The set includes:
933
+ //
934
+ // - every uniform-ref drawHeader name (post-RO uniforms the decoder
935
+ // loads per draw),
936
+ // - every attribute-ref drawHeader name (Sg.instanced's
937
+ // `instanceUniforms` rewrites these from Uniform → Input scope
938
+ // at Effect.compile time, but `mergeStages` accesses the raw
939
+ // stage template that still carries the original `Uniform` decl
940
+ // + Uniform-scope reads; renaming covers both shapes so neither
941
+ // a stale Uniform decl nor a stale Uniform-scope read survives).
942
+ //
943
+ // Names not in the drawHeader stay untouched (currently none in the
944
+ // heap-everything path).
945
+ const uniformRefNames = new Set<string>();
946
+ for (const f of layout.drawHeaderFields) {
947
+ if (f.kind === "uniform-ref" || f.kind === "attribute-ref") uniformRefNames.add(f.name);
948
+ }
949
+ userModule = uniformsToInputs(userModule, uniformRefNames);
950
+
951
+ // 3. Build the decoder Module. Mode flows through: standalone owns
952
+ // the megacall search; family-member receives drawIdx/instId/vid
953
+ // from the wrapper VS as inputs and skips the search.
954
+ const decoderModule = synthesizeHeapDecoderModule(layout, mode);
955
+
956
+ // 4. Concatenate. Decoder values come first so the storage-buffer
957
+ // bindings land at @binding(0)..@binding(6) consistently across
958
+ // every emitted shader (the WGSL emit walks values in order and
959
+ // assigns binding slots).
960
+ let combined: Module = {
961
+ types: [...decoderModule.types, ...userModule.types],
962
+ values: [...decoderModule.values, ...userModule.values],
963
+ };
964
+
965
+ // 5. Standard preprocessing.
966
+ combined = liftReturns(combined);
967
+ combined = injectVsBuiltins(combined);
968
+
969
+ // 6. FS-side atlas rewrite (texture-sample → atlas-sample, reading
970
+ // from the carrier varyings the decoder wrote).
971
+ combined = rewriteFsAtlasTexturesViaCarrier(combined, layout);
972
+
973
+ // 7. Compile.
974
+ const compiled = compileModule(combined, compileOptions);
975
+ if ((globalThis as { __HEAP_DEBUG_FULL__?: boolean }).__HEAP_DEBUG_FULL__) {
976
+ for (const s of compiled.stages) console.log(`[heap-debug-full] ${s.stage}:\n${s.source}`);
977
+ }
978
+ const vsStage = compiled.stages.find(s => s.stage === "vertex");
979
+ const fsStage = compiled.stages.find(s => s.stage === "fragment");
980
+ let vsSrc = vsStage?.source ?? "";
981
+ let fsSrc = fsStage?.source ?? "";
982
+
983
+ // 8. Family-member mode: re-shape the @vertex / @fragment entries
984
+ // into plain function helpers the family wrapper can call. The body
985
+ // is already correct (decoder + user-VS fused, all carrier values
986
+ // computed); the entry headers just need to lose their stage
987
+ // decorations and let location-decorated params become plain u32
988
+ // function arguments.
989
+ if (mode === "family-member") {
990
+ if (vsSrc.length > 0) vsSrc = reshapeFamilyMemberVs(vsSrc);
991
+ if (fsSrc.length > 0) fsSrc = reshapeFamilyMemberFs(fsSrc);
992
+ }
993
+
994
+ // 9. Final WGSL hygiene: atlas-routed texture/sampler bindings are
995
+ // semantically gone (all sampling now goes through the atlas
996
+ // helpers + page arrays), but emit may still produce decls for
997
+ // them. Strip + prepend the atlas-sample helper library on FS.
998
+ if (layout.atlasTextureBindings.size > 0) {
999
+ if (vsSrc.length > 0) vsSrc = stripAtlasTextureSamplerDecls(vsSrc, layout.atlasTextureBindings);
1000
+ if (fsSrc.length > 0) {
1001
+ fsSrc = stripAtlasTextureSamplerDecls(fsSrc, layout.atlasTextureBindings);
1002
+ fsSrc = prependAtlasPrelude(fsSrc);
1003
+ }
1004
+ }
1005
+
1006
+ return {
1007
+ vs: vsSrc,
1008
+ fs: fsSrc,
1009
+ vsEntry: vsStage?.entryName ?? "vs",
1010
+ fsEntry: fsStage?.entryName ?? "fs",
1011
+ preludeWgsl: "",
1012
+ };
1013
+ }
1014
+
1015
+ /**
1016
+ * Family-member VS reshape — surface `(heap_drawIdx, instId, vid)` as
1017
+ * plain `u32` parameters of the helper fn so the family wrapper VS
1018
+ * can call it directly with the megacall-search outputs.
1019
+ *
1020
+ * The decoder synthesised the three names as `@location(N)`-decorated
1021
+ * inputs. After composeStages they live inside the entry's input
1022
+ * struct as `in.heap_drawIdx` / `in.instId` / `in.vid`. This pass:
1023
+ *
1024
+ * 1. Replaces the `(in: <InputStruct>)` parameter with three
1025
+ * plain `u32` parameters by the same names.
1026
+ * 2. Inserts a body prologue that builds the input struct locally
1027
+ * and seeds its three megacall fields from the new params, so
1028
+ * the rest of the body (referring to `in.X`) keeps working.
1029
+ *
1030
+ * `@vertex`/`@fragment` decorations are NOT stripped here —
1031
+ * `heapShaderFamily.stripStageDecoration` does that during wrapper
1032
+ * assembly. Leaving them lets `readEntryName` find the helper name.
1033
+ *
1034
+ * DCE may have dropped one or both of `instId` / `vid` from the
1035
+ * input struct when nothing in the fused body reads them. The
1036
+ * pass detects this and emits a parameter only for fields that
1037
+ * survived; the wrapper can still pass three args (extras are
1038
+ * ignored by WGSL only if the signature accepts them — actually
1039
+ * they're not, so we DO need to keep all three names so the
1040
+ * wrapper's `family_X(drawIdx, instId, vid)` call typechecks).
1041
+ *
1042
+ * The three params are always emitted regardless of liveness; an
1043
+ * unused param is harmless and keeps the wrapper call site stable.
1044
+ */
1045
+ function reshapeFamilyMemberVs(src: string): string {
1046
+ return reshapeFamilyMember(src, /^@vertex\s/m);
1047
+ }
1048
+
1049
+ /** Family-member FS reshape — currently same shape as VS. */
1050
+ function reshapeFamilyMemberFs(src: string): string {
1051
+ return reshapeFamilyMember(src, /^@fragment\s/m);
1052
+ }
1053
+
1054
+ function reshapeFamilyMember(src: string, stageRe: RegExp): string {
1055
+ // Locate the @vertex / @fragment fn declaration.
1056
+ const stageMatch = stageRe.exec(src);
1057
+ if (stageMatch === null) return src;
1058
+ const fnRe = /fn\s+(\w+)\s*\(([^)]*)\)\s*(->\s*\w+)?\s*\{/y;
1059
+ fnRe.lastIndex = stageMatch.index + stageMatch[0].length;
1060
+ // Walk back over whitespace between the stage attr and `fn`.
1061
+ let i = fnRe.lastIndex;
1062
+ while (/\s/.test(src[i - 1] ?? "")) i--;
1063
+ fnRe.lastIndex = i;
1064
+ const fnMatch = fnRe.exec(src);
1065
+ if (fnMatch === null) {
1066
+ // Try without leading-whitespace assumption — find the next `fn ...`.
1067
+ const loose = /fn\s+(\w+)\s*\(([^)]*)\)\s*(->\s*\w+)?\s*\{/.exec(src.slice(stageMatch.index));
1068
+ if (loose === null) return src;
1069
+ return rewriteFn(src, stageMatch.index + loose.index!, loose);
1070
+ }
1071
+ return rewriteFn(src, fnMatch.index, fnMatch);
1072
+ }
1073
+
1074
+ function rewriteFn(src: string, fnStart: number, fnMatch: RegExpExecArray): string {
1075
+ const fnName = fnMatch[1]!;
1076
+ const paramsRaw = fnMatch[2]!;
1077
+ const returnPart = fnMatch[3] ?? "";
1078
+ const params = paramsRaw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
1079
+
1080
+ // Identify the `in: <InputStruct>` param (if any).
1081
+ let inStructName: string | undefined;
1082
+ const otherParams: string[] = [];
1083
+ for (const p of params) {
1084
+ const m = /^in\s*:\s*(\w+)$/.exec(p);
1085
+ if (m !== null && inStructName === undefined) {
1086
+ inStructName = m[1]!;
1087
+ continue;
1088
+ }
1089
+ otherParams.push(p);
1090
+ }
1091
+
1092
+ // New parameter list: three megacall params first, then any other
1093
+ // params (rare; usually only `in`).
1094
+ const newParams = [
1095
+ "heap_drawIdx: u32",
1096
+ "instId: u32",
1097
+ "vid: u32",
1098
+ ...otherParams,
1099
+ ].join(", ");
1100
+
1101
+ // Body prologue: only needed when the original entry actually had an
1102
+ // input struct that the body reads via `in.X`. We inject a local
1103
+ // `in` and seed its three megacall fields. Any other fields the
1104
+ // entry declared (vertex attribute Inputs, post-rename uniforms) are
1105
+ // satisfied via the fused State struct — they never read from `in`.
1106
+ const prologue = inStructName === undefined
1107
+ ? ""
1108
+ : `\n var in: ${inStructName};\n in.heap_drawIdx = heap_drawIdx;\n in.instId = instId;\n in.vid = vid;\n`;
1109
+
1110
+ // Splice the new signature in.
1111
+ const before = src.slice(0, fnStart);
1112
+ const headerLen = fnMatch[0]!.length;
1113
+ // The match captured up to and including the `{`. Anything inside the
1114
+ // body follows.
1115
+ const bodyStart = fnStart + headerLen;
1116
+ const newHeader = `fn ${fnName}(${newParams})${returnPart ? " " + returnPart : ""} {${prologue}`;
1117
+ return before + newHeader + src.slice(bodyStart);
1118
+ }
1119
+
1120
+ /**
1121
+ * FS-side atlas rewrite for the decoder-composition path.
1122
+ *
1123
+ * The decoder VS has already written `_h_<name>{PageRef,FormatBits,
1124
+ * Origin,Size}` onto the inter-stage carrier. This pass:
1125
+ * 1. Rewrites each `textureSample(t, smp, uv)` (or shipped
1126
+ * `texture(...)` shorthand) for an atlas-routed binding into the
1127
+ * atlas-sample intrinsic reading from `Input.<carrier name>`.
1128
+ * 2. Adds matching `EntryParameter`s on every FS Entry so the WGSL
1129
+ * emit produces an Input struct that actually contains the
1130
+ * `_h_<name><Sub>` fields the body reads.
1131
+ *
1132
+ * Unlike the legacy `rewriteFsAtlasTextures`, this does NOT inject
1133
+ * VS-side WriteOutputs (the decoder writes them already).
1134
+ */
1135
+ function rewriteFsAtlasTexturesViaCarrier(m: Module, layout: BucketLayout): Module {
1136
+ if (layout.atlasTextureBindings.size === 0) return m;
1137
+
1138
+ // Find which atlas-routed names the FS actually samples — only those
1139
+ // need carrier declarations and rewrites.
1140
+ const used = new Set<string>();
1141
+ for (const v of m.values) {
1142
+ if (v.kind !== "Entry" || v.entry.stage !== "fragment") continue;
1143
+ visitStmtExprs(v.entry.body, e => {
1144
+ const hit = isAtlasSampleCall(e, layout.atlasTextureBindings);
1145
+ if (hit !== null) used.add(hit.name);
1146
+ });
1147
+ }
1148
+ if (used.size === 0) return m;
1149
+
1150
+ // Build the four carrier inputs per used texture. Flat interpolation
1151
+ // — integer types require it and the floats are constant per draw.
1152
+ const fsExtras: EntryParameter[] = [];
1153
+ for (const name of used) {
1154
+ const v = atlasVaryingNames(name);
1155
+ const flat: ParamDecoration = { kind: "Interpolation", mode: "flat" };
1156
+ fsExtras.push({ name: v.pageRef, type: Tu32, semantic: v.pageRef, decorations: [flat] });
1157
+ fsExtras.push({ name: v.formatBits, type: Tu32, semantic: v.formatBits, decorations: [flat] });
1158
+ fsExtras.push({ name: v.origin, type: Tvec2, semantic: v.origin, decorations: [flat] });
1159
+ fsExtras.push({ name: v.size, type: Tvec2, semantic: v.size, decorations: [flat] });
1160
+ }
1161
+
1162
+ // Add the EntryParameter declarations to every FS entry's inputs.
1163
+ let out = m;
1164
+ out = {
1165
+ ...out,
1166
+ values: out.values.map((vv) => {
1167
+ if (vv.kind !== "Entry" || vv.entry.stage !== "fragment") return vv;
1168
+ const haveInput = new Set(vv.entry.inputs.map(p => p.name));
1169
+ const additions = fsExtras.filter(p => !haveInput.has(p.name));
1170
+ if (additions.length === 0) return vv;
1171
+ return { ...vv, entry: { ...vv.entry, inputs: [...vv.entry.inputs, ...additions] } };
1172
+ }),
1173
+ };
1174
+
1175
+ // Body rewrite: textureSample → atlasSample(Input.pageRef, …, uv).
1176
+ out = mapStageEntryBodies(out, "fragment", body => mapStmt(body, {
1177
+ expr: e => mapExpr(e, sub => {
1178
+ const hit = isAtlasSampleCall(sub, layout.atlasTextureBindings);
1179
+ if (hit === null) return sub;
1180
+ const v = atlasVaryingNames(hit.name);
1181
+ const args: Expr[] = [
1182
+ readScope("Input", v.pageRef, Tu32),
1183
+ readScope("Input", v.formatBits, Tu32),
1184
+ readScope("Input", v.origin, Tvec2),
1185
+ readScope("Input", v.size, Tvec2),
1186
+ hit.uv,
1187
+ ];
1188
+ return { kind: "CallIntrinsic", op: ATLAS_SAMPLE_INTRINSIC, args, type: Tvec4 };
1189
+ }),
1190
+ }));
1191
+ return out;
1192
+ }
1193
+
883
1194
  /**
884
1195
  * Drop `@group(N) @binding(M) var <name>: texture_*<...>;` and the
885
1196
  * matching sampler decl(s) for atlas-routed binding names. We keep
@@ -40,6 +40,7 @@ import type { BufferView } from "../core/bufferView.js";
40
40
  import type { ITexture } from "../core/texture.js";
41
41
  import type { ISampler } from "../core/sampler.js";
42
42
  import type { RenderObject } from "../core/renderObject.js";
43
+ import { asAttributeProvider } from "../core/provider.js";
43
44
 
44
45
  /**
45
46
  * Maximum texture extent (per side) the heap path is willing to
@@ -82,6 +83,8 @@ const isHeapServableSampler = (_s: ISampler): boolean => true;
82
83
  */
83
84
  function isHeapServableTexture(t: ITexture): boolean {
84
85
  if (t.kind === "gpu") return false;
86
+ // URL-deferred — the Sg layer hands us a placeholder while it loads.
87
+ if (t.kind === "url") return false;
85
88
  const src = t.source;
86
89
  if (src.kind === "raw") {
87
90
  if ((src.depthOrArrayLayers ?? 1) !== 1) return false;
@@ -107,11 +110,21 @@ function isHeapServableTexture(t: ITexture): boolean {
107
110
  return true;
108
111
  }
109
112
 
113
+ // Materialise every attribute view a provider knows about. Eligibility
114
+ // is decided before any shader is compiled, so we can't pull
115
+ // shader-driven here — we enumerate. Attribute providers are map-backed
116
+ // in practice, so `names()` is the full set and `tryGet` is a map hit.
117
+ function attrViews(p: { names(): Iterable<string>; tryGet(n: string): BufferView | undefined }): BufferView[] {
118
+ const out: BufferView[] = [];
119
+ for (const n of p.names()) { const v = p.tryGet(n); if (v !== undefined) out.push(v); }
120
+ return out;
121
+ }
122
+
110
123
  function bufferAvals(ro: RenderObject): aval<IBuffer>[] {
111
124
  const out: aval<IBuffer>[] = [];
112
- ro.vertexAttributes.iter((_k, v: BufferView) => { out.push(v.buffer); });
125
+ for (const v of attrViews(asAttributeProvider(ro.vertexAttributes))) out.push(v.buffer);
113
126
  if (ro.instanceAttributes !== undefined) {
114
- ro.instanceAttributes.iter((_k, v: BufferView) => { out.push(v.buffer); });
127
+ for (const v of attrViews(asAttributeProvider(ro.instanceAttributes))) out.push(v.buffer);
115
128
  }
116
129
  if (ro.indices !== undefined) out.push(ro.indices.buffer);
117
130
  return out;
@@ -139,7 +152,23 @@ export function isHeapEligible(ro: RenderObject): aval<boolean> {
139
152
  if (ro.storageBuffers !== undefined && ro.storageBuffers.count > 0) {
140
153
  return AVal.constant(false);
141
154
  }
142
- if (ro.textures.count > 1 || ro.samplers.count > 1) {
155
+ // Heap path serves at most one distinct texture + sampler per bucket
156
+ // (the megacall draw has one bind group). The Sg compile layer
157
+ // over-binds each texture under both `name` and `${name}_view` (so
158
+ // whichever name the WGSL schema lands on resolves), so dedupe by
159
+ // aval identity before applying the single-binding rule — otherwise
160
+ // every textured leaf would be `count > 1` and fall to the legacy
161
+ // per-RO path, which at scale (thousands of textured ROs ⇒ thousands
162
+ // of draw calls + bind-group switches) is far slower than the heap
163
+ // megacall sampling one shared atlas page.
164
+ const distinctTextureAvals = new Set<aval<ITexture>>();
165
+ ro.textures.iter((_n, av) => { distinctTextureAvals.add(av); });
166
+ if (distinctTextureAvals.size > 1) {
167
+ return AVal.constant(false);
168
+ }
169
+ const distinctSamplerAvals = new Set<aval<ISampler>>();
170
+ ro.samplers.iter((_n, av) => { distinctSamplerAvals.add(av); });
171
+ if (distinctSamplerAvals.size > 1) {
143
172
  return AVal.constant(false);
144
173
  }
145
174
 
@@ -155,9 +184,9 @@ export function isHeapEligible(ro: RenderObject): aval<boolean> {
155
184
  const isBroadcast = v.singleValue !== undefined || stride === 0;
156
185
  if (!isBroadcast && stride !== v.elementType.byteSize) strideBlocked = true;
157
186
  };
158
- ro.vertexAttributes.iter((_k, v: BufferView) => checkStride(v));
187
+ for (const v of attrViews(asAttributeProvider(ro.vertexAttributes))) checkStride(v);
159
188
  if (ro.instanceAttributes !== undefined) {
160
- ro.instanceAttributes.iter((_k, v: BufferView) => checkStride(v));
189
+ for (const v of attrViews(asAttributeProvider(ro.instanceAttributes))) checkStride(v);
161
190
  }
162
191
  if (strideBlocked) return AVal.constant(false);
163
192