@forgeax/engine-render 0.1.6 → 0.1.7
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.
- package/README.md +139 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/gpu-driven-indirect-raster-evidence.d.ts.map +1 -1
- package/dist/assembly/factory.d.ts +7 -0
- package/dist/assembly/factory.d.ts.map +1 -1
- package/dist/assembly/material/assembly.d.ts.map +1 -1
- package/dist/authoring.d.ts +1 -0
- package/dist/authoring.d.ts.map +1 -1
- package/dist/authoring.mjs +1 -1
- package/dist/{chunk-QC4VW3XJ.mjs → chunk-HCOGPHKS.mjs} +17 -3
- package/dist/chunk-HCOGPHKS.mjs.map +1 -0
- package/dist/chunk-YEJIUW7D.mjs +1152 -0
- package/dist/chunk-YEJIUW7D.mjs.map +1 -0
- package/dist/components/__tests__/points-lines-admission.unit.test.d.ts +2 -0
- package/dist/components/__tests__/points-lines-admission.unit.test.d.ts.map +1 -0
- package/dist/components/__tests__/points-lines-schema.unit.test.d.ts +2 -0
- package/dist/components/__tests__/points-lines-schema.unit.test.d.ts.map +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/lines.d.ts +5 -0
- package/dist/components/lines.d.ts.map +1 -0
- package/dist/components/points.d.ts +15 -0
- package/dist/components/points.d.ts.map +1 -0
- package/dist/construct-renderer.mjs +952 -497
- package/dist/construct-renderer.mjs.map +1 -1
- package/dist/errors/render.d.ts +82 -1
- package/dist/errors/render.d.ts.map +1 -1
- package/dist/features/render-graph-raster.d.ts +5 -1
- package/dist/features/render-graph-raster.d.ts.map +1 -1
- package/dist/gpu-driven/production-raster.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +4 -2
- package/dist/index.mjs.map +1 -1
- package/dist/inspection-types.d.ts +3 -0
- package/dist/inspection-types.d.ts.map +1 -1
- package/dist/pbr-pipeline.d.ts +2 -0
- package/dist/pbr-pipeline.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/points-lines/__tests__/bounds.unit.test.d.ts +2 -0
- package/dist/points-lines/__tests__/bounds.unit.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/cache-recovery.unit.test.d.ts +2 -0
- package/dist/points-lines/__tests__/cache-recovery.unit.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/inspection-lanes.integration.test.d.ts +2 -0
- package/dist/points-lines/__tests__/inspection-lanes.integration.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/inspection.unit.test.d.ts +2 -0
- package/dist/points-lines/__tests__/inspection.unit.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/lane-structural.integration.test.d.ts +2 -0
- package/dist/points-lines/__tests__/lane-structural.integration.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/points-lines.bench.d.ts +2 -0
- package/dist/points-lines/__tests__/points-lines.bench.d.ts.map +1 -0
- package/dist/points-lines/__tests__/raster-lane.integration.test.d.ts +2 -0
- package/dist/points-lines/__tests__/raster-lane.integration.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/snapshot.unit.test.d.ts +2 -0
- package/dist/points-lines/__tests__/snapshot.unit.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/view-ubo.unit.test.d.ts +2 -0
- package/dist/points-lines/__tests__/view-ubo.unit.test.d.ts.map +1 -0
- package/dist/points-lines/admission.d.ts +36 -0
- package/dist/points-lines/admission.d.ts.map +1 -0
- package/dist/points-lines/bounds.d.ts +8 -0
- package/dist/points-lines/bounds.d.ts.map +1 -0
- package/dist/points-lines/expansion-cache.d.ts +28 -0
- package/dist/points-lines/expansion-cache.d.ts.map +1 -0
- package/dist/points-lines/inspection.d.ts +53 -0
- package/dist/points-lines/inspection.d.ts.map +1 -0
- package/dist/points-lines/prepare.d.ts +67 -0
- package/dist/points-lines/prepare.d.ts.map +1 -0
- package/dist/points-lines/record.d.ts +60 -0
- package/dist/points-lines/record.d.ts.map +1 -0
- package/dist/points-lines/snapshot.d.ts +49 -0
- package/dist/points-lines/snapshot.d.ts.map +1 -0
- package/dist/record/frame-lighting.d.ts.map +1 -1
- package/dist/record/frame.d.ts +1 -1
- package/dist/record/frame.d.ts.map +1 -1
- package/dist/record/main-pass-geometry.d.ts +7 -0
- package/dist/record/main-pass-geometry.d.ts.map +1 -1
- package/dist/record/main-pass.d.ts.map +1 -1
- package/dist/record/render-context.d.ts +17 -0
- package/dist/record/render-context.d.ts.map +1 -1
- package/dist/record/shadow-pass.d.ts.map +1 -1
- package/dist/record/skybox-post-pass.d.ts.map +1 -1
- package/dist/record/typed-frame-graph.d.ts.map +1 -1
- package/dist/record/view-ubo.d.ts +6 -0
- package/dist/record/view-ubo.d.ts.map +1 -1
- package/dist/render-contract.d.ts +10 -2
- package/dist/render-contract.d.ts.map +1 -1
- package/dist/render-system-extract.d.ts +3 -0
- package/dist/render-system-extract.d.ts.map +1 -1
- package/dist/render-system.d.ts +4 -1
- package/dist/render-system.d.ts.map +1 -1
- package/dist/scene/render-scene-types.d.ts +3 -0
- package/dist/scene/render-scene-types.d.ts.map +1 -1
- package/dist/scene/render-scene.d.ts +7 -0
- package/dist/scene/render-scene.d.ts.map +1 -1
- package/package.json +19 -19
- package/src/__tests__/authoring-path.unit.test.ts +9 -0
- package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +12 -2
- package/src/__tests__/material-cooked-projection.unit.test.ts +29 -7
- package/src/__tests__/material-static-dynamic-route.unit.test.ts +28 -28
- package/src/__tests__/render-error-code-owner.test-d.ts +6 -0
- package/src/__tests__/render-error-exhaustive.test-d.ts +19 -0
- package/src/__tests__/renderer-factory-material-contract.unit.test.ts +38 -0
- package/src/assembly/factory.ts +72 -3
- package/src/assembly/material/assembly.ts +2 -5
- package/src/authoring.ts +1 -0
- package/src/components/__tests__/points-lines-admission.unit.test.ts +219 -0
- package/src/components/__tests__/points-lines-schema.unit.test.ts +56 -0
- package/src/components/index.ts +7 -0
- package/src/components/lines.ts +18 -0
- package/src/components/points.ts +40 -0
- package/src/errors/render.ts +144 -1
- package/src/features/render-graph-raster.ts +22 -3
- package/src/gpu-driven/production-raster.ts +6 -1
- package/src/index.ts +17 -0
- package/src/inspection-types.ts +4 -0
- package/src/pbr-pipeline.ts +8 -1
- package/src/plugin.ts +4 -0
- package/src/points-lines/__tests__/bounds.unit.test.ts +21 -0
- package/src/points-lines/__tests__/cache-recovery.unit.test.ts +157 -0
- package/src/points-lines/__tests__/inspection-lanes.integration.test.ts +146 -0
- package/src/points-lines/__tests__/inspection.unit.test.ts +173 -0
- package/src/points-lines/__tests__/lane-structural.integration.test.ts +122 -0
- package/src/points-lines/__tests__/points-lines.bench.ts +400 -0
- package/src/points-lines/__tests__/raster-lane.integration.test.ts +44 -0
- package/src/points-lines/__tests__/snapshot.unit.test.ts +101 -0
- package/src/points-lines/__tests__/view-ubo.unit.test.ts +73 -0
- package/src/points-lines/admission.ts +238 -0
- package/src/points-lines/bounds.ts +23 -0
- package/src/points-lines/expansion-cache.ts +215 -0
- package/src/points-lines/inspection.ts +97 -0
- package/src/points-lines/prepare.ts +177 -0
- package/src/points-lines/record.ts +125 -0
- package/src/points-lines/snapshot.ts +130 -0
- package/src/record/frame-lighting.ts +14 -2
- package/src/record/frame.ts +12 -1
- package/src/record/main-pass-geometry.ts +92 -0
- package/src/record/main-pass-sprite-draws.ts +1 -1
- package/src/record/main-pass.ts +6 -3
- package/src/record/render-context.ts +20 -0
- package/src/record/shadow-pass.ts +16 -5
- package/src/record/skybox-post-pass.ts +2 -1
- package/src/record/typed-frame-graph.ts +17 -16
- package/src/record/view-ubo.ts +47 -0
- package/src/render-contract.ts +13 -2
- package/src/render-system-extract.ts +71 -8
- package/src/render-system.ts +304 -2
- package/src/scene/render-scene-types.ts +3 -0
- package/src/scene/render-scene.ts +57 -2
- package/dist/chunk-QC4VW3XJ.mjs.map +0 -1
- package/dist/chunk-SCRSAK5B.mjs +0 -480
- package/dist/chunk-SCRSAK5B.mjs.map +0 -1
|
@@ -43,12 +43,19 @@ export type RenderFeatureGraphTargetResolver = (
|
|
|
43
43
|
resource: string | RenderFeatureTargetHandle,
|
|
44
44
|
) => RenderFeatureGraphTarget | undefined;
|
|
45
45
|
|
|
46
|
+
export type RenderFeatureGraphBindingsResolution =
|
|
47
|
+
| BindGroup
|
|
48
|
+
| {
|
|
49
|
+
readonly handle: BindGroup;
|
|
50
|
+
readonly dynamicOffsets?: readonly number[];
|
|
51
|
+
};
|
|
52
|
+
|
|
46
53
|
export type RenderFeatureGraphBindingsResolver<FrameCtx extends RenderGraphFrame> = (input: {
|
|
47
54
|
readonly frame: FrameCtx;
|
|
48
55
|
readonly binding: Extract<PreparedGraphicsResolvedResource, { readonly kind: 'bindings' }>;
|
|
49
56
|
readonly resources: GraphResourceResolver;
|
|
50
57
|
readonly resolveTarget: RenderFeatureGraphTargetResolver;
|
|
51
|
-
}) =>
|
|
58
|
+
}) => RenderFeatureGraphBindingsResolution | undefined;
|
|
52
59
|
|
|
53
60
|
interface ResolvedDrawBuffers {
|
|
54
61
|
readonly vertex: ReadonlyMap<object, GraphBuffer>;
|
|
@@ -255,12 +262,24 @@ export class RenderFeatureRasterGraphProjection<FrameCtx extends RenderGraphFram
|
|
|
255
262
|
for (const [group, reference] of draw.bindings.entries()) {
|
|
256
263
|
const binding = resolved(snapshot, reference);
|
|
257
264
|
if (binding?.kind !== 'bindings') throw missing(featureIdentity, order);
|
|
258
|
-
const
|
|
265
|
+
const resolvedBindings =
|
|
259
266
|
binding.handle ??
|
|
260
267
|
this.resolveBindings?.({ frame, binding, resources, resolveTarget: this.resolveTarget });
|
|
268
|
+
const handle =
|
|
269
|
+
resolvedBindings !== undefined &&
|
|
270
|
+
typeof resolvedBindings === 'object' &&
|
|
271
|
+
'handle' in resolvedBindings
|
|
272
|
+
? resolvedBindings.handle
|
|
273
|
+
: resolvedBindings;
|
|
261
274
|
if (handle === undefined) throw missing(featureIdentity, order);
|
|
262
275
|
const targetGroup = binding.descriptor?.values.group ?? group;
|
|
263
|
-
|
|
276
|
+
const dynamicOffsets =
|
|
277
|
+
resolvedBindings !== undefined &&
|
|
278
|
+
typeof resolvedBindings === 'object' &&
|
|
279
|
+
'handle' in resolvedBindings
|
|
280
|
+
? resolvedBindings.dynamicOffsets
|
|
281
|
+
: binding.dynamicOffsets;
|
|
282
|
+
pass.setBindGroup(targetGroup, handle, dynamicOffsets);
|
|
264
283
|
}
|
|
265
284
|
for (const vertex of draw.vertexData) {
|
|
266
285
|
const handle = buffers.vertex.get(vertex.resource as object);
|
|
@@ -606,7 +606,12 @@ export class GpuDrivenProduction {
|
|
|
606
606
|
return ok({
|
|
607
607
|
accesses,
|
|
608
608
|
encode: (viewBindGroup, pass, resources: GraphResourceResolver) => {
|
|
609
|
-
|
|
609
|
+
// pbr-view-bgl carries the vertex-only Points/Lines UBO at binding
|
|
610
|
+
// 10 as a dynamic uniform buffer. The production view group is
|
|
611
|
+
// shared by the GPU-driven raster pipeline, so even rigid draws
|
|
612
|
+
// must provide the binding-10 offset (zero selects the frame
|
|
613
|
+
// default slot).
|
|
614
|
+
pass.setBindGroup(0, viewBindGroup, [0]);
|
|
610
615
|
let currentPipeline: RenderPipeline | undefined;
|
|
611
616
|
let currentVertex: GraphBuffer | undefined;
|
|
612
617
|
let currentIndex: GraphBuffer | undefined;
|
package/src/index.ts
CHANGED
|
@@ -38,10 +38,17 @@ export {
|
|
|
38
38
|
export * from './components/directional-light';
|
|
39
39
|
export { Instances, type InstancesData } from './components/instances';
|
|
40
40
|
export { Layer } from './components/layer';
|
|
41
|
+
export { Lines } from './components/lines';
|
|
41
42
|
export * from './components/mesh-filter';
|
|
42
43
|
export * from './components/mesh-renderer';
|
|
43
44
|
export { PointLight } from './components/point-light';
|
|
44
45
|
export { PointLightShadow } from './components/point-light-shadow';
|
|
46
|
+
export {
|
|
47
|
+
type PointShape,
|
|
48
|
+
PointShapeValue,
|
|
49
|
+
Points,
|
|
50
|
+
pointShapeFromU32,
|
|
51
|
+
} from './components/points';
|
|
45
52
|
export { PostProcessParams } from './components/post-process-params';
|
|
46
53
|
export { SceneInstance } from './components/scene-instance';
|
|
47
54
|
export {
|
|
@@ -91,6 +98,16 @@ export { Materials } from './materials';
|
|
|
91
98
|
// pipeline helpers remain behind the package boundary.
|
|
92
99
|
export { DEFAULT_STANDARD_PROFILE } from './pipeline/standard-profile';
|
|
93
100
|
export { renderComponentsPlugin } from './plugin';
|
|
101
|
+
export {
|
|
102
|
+
admitPointsLines,
|
|
103
|
+
type LinesStyleInput,
|
|
104
|
+
type PointsLinesAdmission,
|
|
105
|
+
type PointsLinesAdmissionError,
|
|
106
|
+
type PointsLinesAdmissionInput,
|
|
107
|
+
type PointsLinesAdmissionLimits,
|
|
108
|
+
type PointsStyleInput,
|
|
109
|
+
} from './points-lines/admission';
|
|
110
|
+
export type { PointsLinesInspection } from './points-lines/inspection';
|
|
94
111
|
// Runtime contract: leases, frame receipts, detached inspection, profile,
|
|
95
112
|
// lifecycle state, and the single renderer event stream.
|
|
96
113
|
export type {
|
package/src/inspection-types.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* extract/record dependency graph.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { PointsLinesInspection } from './points-lines/inspection';
|
|
8
|
+
|
|
7
9
|
export interface BatchTopologyInspection {
|
|
8
10
|
readonly revision: number;
|
|
9
11
|
readonly batchCount: number;
|
|
@@ -69,6 +71,8 @@ export interface PersistentRenderSceneInspection {
|
|
|
69
71
|
| ({ readonly status: 'resident' } & GpuSceneInspection)
|
|
70
72
|
| { readonly status: 'rebuild-pending' }
|
|
71
73
|
| { readonly status: 'error' };
|
|
74
|
+
/** Bounded retained authoring observations; no graph or backend handles. */
|
|
75
|
+
readonly pointsLines: readonly PointsLinesInspection[];
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
export type RenderSceneInspection = PersistentRenderSceneInspection & {
|
package/src/pbr-pipeline.ts
CHANGED
|
@@ -347,6 +347,8 @@ export interface PbrCaps {
|
|
|
347
347
|
* (always-on bindings 5/6 paired with the unconditional `POINT_SHADOW_AVAILABLE`
|
|
348
348
|
* define registered in vite-plugin-shader) +
|
|
349
349
|
* feat-20260613-csm-cascaded-shadow-maps M5 / w28 (binding 7 cascade UBO).
|
|
350
|
+
* binding 10 (Points/Lines viewport UBO; always bound for the shared view
|
|
351
|
+
* layout and consumed only by the dedicated points-lines shader).
|
|
350
352
|
* Isolated here so M4 round-4 tests can recreate the layout.
|
|
351
353
|
*
|
|
352
354
|
* feat-20260526-pbr-uniform-fallback-no-storage-buffer M3 / w9:
|
|
@@ -448,6 +450,11 @@ export function buildPbrViewBglEntries(caps: PbrCaps): GPUBindGroupLayoutEntry[]
|
|
|
448
450
|
visibility: GPU_SHADER_STAGE_FRAGMENT,
|
|
449
451
|
texture: { sampleType: 'depth', viewDimension: '2d' },
|
|
450
452
|
},
|
|
453
|
+
{
|
|
454
|
+
binding: 10,
|
|
455
|
+
visibility: GPU_SHADER_STAGE_VERTEX,
|
|
456
|
+
buffer: { type: 'uniform', hasDynamicOffset: true },
|
|
457
|
+
},
|
|
451
458
|
];
|
|
452
459
|
}
|
|
453
460
|
|
|
@@ -1005,7 +1012,7 @@ export function buildBindGroupLayoutDescriptor(
|
|
|
1005
1012
|
const desc = createHdrpBindGroupLayoutDescriptor(caps.storageBuffer);
|
|
1006
1013
|
return {
|
|
1007
1014
|
label: desc.label ?? 'hdrp-unified-bgl-group2',
|
|
1008
|
-
entries: desc.entries ?? [],
|
|
1015
|
+
entries: [...(desc.entries ?? [])],
|
|
1009
1016
|
};
|
|
1010
1017
|
}
|
|
1011
1018
|
case 'fullscreen-post': {
|
package/src/plugin.ts
CHANGED
|
@@ -4,10 +4,12 @@ import { Camera } from './components/camera';
|
|
|
4
4
|
import { DirectionalLight } from './components/directional-light';
|
|
5
5
|
import { Instances } from './components/instances';
|
|
6
6
|
import { Layer } from './components/layer';
|
|
7
|
+
import { Lines } from './components/lines';
|
|
7
8
|
import { MeshFilter } from './components/mesh-filter';
|
|
8
9
|
import { MeshRenderer } from './components/mesh-renderer';
|
|
9
10
|
import { PointLight } from './components/point-light';
|
|
10
11
|
import { PointLightShadow } from './components/point-light-shadow';
|
|
12
|
+
import { Points } from './components/points';
|
|
11
13
|
import { PostProcessParams } from './components/post-process-params';
|
|
12
14
|
import { SceneInstance } from './components/scene-instance';
|
|
13
15
|
import { SkyboxBackground } from './components/skybox-background';
|
|
@@ -30,6 +32,8 @@ const RENDER_COMPONENTS: readonly Component[] = [
|
|
|
30
32
|
MeshRenderer,
|
|
31
33
|
PointLight,
|
|
32
34
|
PointLightShadow,
|
|
35
|
+
Points,
|
|
36
|
+
Lines,
|
|
33
37
|
PostProcessParams,
|
|
34
38
|
SceneInstance,
|
|
35
39
|
SkyboxBackground,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { expandPointsLinesBounds } from '../bounds';
|
|
3
|
+
|
|
4
|
+
describe('Points/Lines conservative transformed culling bounds', () => {
|
|
5
|
+
it('retains a physical-pixel raster margin around transformed edge geometry', () => {
|
|
6
|
+
const expanded = expandPointsLinesBounds(new Float32Array([10, 20, -1, 30, 40, 1]), {
|
|
7
|
+
kind: 'points',
|
|
8
|
+
sizePx: 16,
|
|
9
|
+
shape: 'circle',
|
|
10
|
+
});
|
|
11
|
+
expect([...expanded]).toEqual([2, 12, -9, 38, 48, 9]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('uses line width as the conservative envelope for off-edge segments', () => {
|
|
15
|
+
const expanded = expandPointsLinesBounds([-1, -1, 0, 1, 1, 0], {
|
|
16
|
+
kind: 'lines',
|
|
17
|
+
widthPx: 4,
|
|
18
|
+
});
|
|
19
|
+
expect([...expanded]).toEqual([-3, -3, -2, 3, 3, 2]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { err, type MeshAsset, ok } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { type PointsLinesExpandedGeometry, PointsLinesExpansionCache } from '../expansion-cache';
|
|
4
|
+
import { type PointsLinesGpuResourceAdapter, PointsLinesPreparation } from '../prepare';
|
|
5
|
+
import { createPointsLinesLaneContract, createPointsLinesRecordPlan } from '../record';
|
|
6
|
+
import { createPointsLinesSnapshot } from '../snapshot';
|
|
7
|
+
|
|
8
|
+
function mesh(generation = 1): MeshAsset {
|
|
9
|
+
return {
|
|
10
|
+
kind: 'mesh',
|
|
11
|
+
vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0]),
|
|
12
|
+
indices: new Uint16Array([0, 1, 2, 3]),
|
|
13
|
+
attributes: { position: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0]) },
|
|
14
|
+
aabb: new Float32Array([0, 0, 0, 1, 1, 0]),
|
|
15
|
+
submeshes: [
|
|
16
|
+
{ indexOffset: 0, indexCount: 4, vertexCount: 4, topology: 'line-list', materialSlot: 0 },
|
|
17
|
+
],
|
|
18
|
+
materialSlots: [{ slotName: `lines-${generation}` }],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function snapshot(meshGeneration = 1, materialGeneration = 1) {
|
|
23
|
+
return createPointsLinesSnapshot({
|
|
24
|
+
worldId: 0,
|
|
25
|
+
entityKey: 4,
|
|
26
|
+
component: 'Lines',
|
|
27
|
+
meshHandle: 9,
|
|
28
|
+
meshGeneration,
|
|
29
|
+
materialHandle: 12,
|
|
30
|
+
materialGeneration,
|
|
31
|
+
style: { kind: 'lines', widthPx: 3 },
|
|
32
|
+
layer: 0,
|
|
33
|
+
visible: true,
|
|
34
|
+
sourceBounds: new Float32Array([0, 0, 0, 1, 1, 0]),
|
|
35
|
+
viewport: { width: 320, height: 200, dpr: 1 },
|
|
36
|
+
projection: new Float32Array(16),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('Points/Lines expansion cache and LKG recovery', () => {
|
|
41
|
+
it('reuses geometry when style changes and keeps style out of the cache key', () => {
|
|
42
|
+
const cache = new PointsLinesExpansionCache();
|
|
43
|
+
const first = cache.getOrCreate(snapshot(), mesh());
|
|
44
|
+
const styled = cache.getOrCreate(
|
|
45
|
+
createPointsLinesSnapshot({
|
|
46
|
+
...snapshot(),
|
|
47
|
+
style: { kind: 'lines', widthPx: 9 },
|
|
48
|
+
}),
|
|
49
|
+
mesh(),
|
|
50
|
+
);
|
|
51
|
+
const material = cache.getOrCreate(snapshot(1, 2), mesh());
|
|
52
|
+
|
|
53
|
+
expect(first).toBe(styled);
|
|
54
|
+
expect(first).toBe(material);
|
|
55
|
+
expect(first.key).toContain('mesh:9:1');
|
|
56
|
+
expect(styled.key).not.toContain('width=9');
|
|
57
|
+
expect(styled.vertices).toEqual(first.vertices);
|
|
58
|
+
expect(styled.positions).toEqual(first.positions);
|
|
59
|
+
expect(first).toEqual<PointsLinesExpandedGeometry>(
|
|
60
|
+
expect.objectContaining({ sourceVertexCount: 4, segmentCount: 2 }),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('destroys only a failed candidate and retains the previous live resource', () => {
|
|
65
|
+
const calls: string[] = [];
|
|
66
|
+
let failValidation = false;
|
|
67
|
+
const adapter: PointsLinesGpuResourceAdapter<string> = {
|
|
68
|
+
create: () => {
|
|
69
|
+
calls.push('create');
|
|
70
|
+
return ok(`resource-${calls.filter((call) => call === 'create').length}`);
|
|
71
|
+
},
|
|
72
|
+
upload: (resource) => {
|
|
73
|
+
calls.push(`upload:${resource}`);
|
|
74
|
+
return ok(64);
|
|
75
|
+
},
|
|
76
|
+
validate: (resource) => {
|
|
77
|
+
calls.push(`validate:${resource}`);
|
|
78
|
+
return failValidation ? err(new Error('candidate validation fault')) : ok(undefined);
|
|
79
|
+
},
|
|
80
|
+
destroy: (resource) => calls.push(`destroy:${resource}`),
|
|
81
|
+
};
|
|
82
|
+
const preparation = new PointsLinesPreparation({
|
|
83
|
+
cache: new PointsLinesExpansionCache(),
|
|
84
|
+
adapter,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const first = preparation.prepare(snapshot(), mesh());
|
|
88
|
+
expect(first.ok).toBe(true);
|
|
89
|
+
failValidation = true;
|
|
90
|
+
const failed = preparation.prepare(snapshot(2), mesh(2));
|
|
91
|
+
|
|
92
|
+
expect(failed.ok).toBe(false);
|
|
93
|
+
expect(calls).toContain('destroy:resource-2');
|
|
94
|
+
expect(preparation.inspect()).toMatchObject({
|
|
95
|
+
status: 'resident',
|
|
96
|
+
lastKnownGood: true,
|
|
97
|
+
liveResource: 'resource-1',
|
|
98
|
+
candidateBytes: 0,
|
|
99
|
+
});
|
|
100
|
+
expect(preparation.inspect().liveBytes).toBe(
|
|
101
|
+
preparation.lastKnownGood()?.geometry.derivedBytes,
|
|
102
|
+
);
|
|
103
|
+
expect(preparation.lastKnownGood()?.resource).toBe('resource-1');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('reuses geometry for a changed style and records its new physical-pixel draw plan', () => {
|
|
107
|
+
let uploads = 0;
|
|
108
|
+
const adapter: PointsLinesGpuResourceAdapter<string> = {
|
|
109
|
+
create: (geometry) => ok(geometry.key),
|
|
110
|
+
upload: (_resource, geometry) => {
|
|
111
|
+
uploads += 1;
|
|
112
|
+
return ok(geometry.derivedBytes);
|
|
113
|
+
},
|
|
114
|
+
validate: () => ok(undefined),
|
|
115
|
+
destroy: () => undefined,
|
|
116
|
+
};
|
|
117
|
+
const preparation = new PointsLinesPreparation({
|
|
118
|
+
cache: new PointsLinesExpansionCache(),
|
|
119
|
+
adapter,
|
|
120
|
+
});
|
|
121
|
+
const first = preparation.prepare(snapshot(), mesh()).unwrap();
|
|
122
|
+
const changedSnapshot = createPointsLinesSnapshot({
|
|
123
|
+
...snapshot(),
|
|
124
|
+
style: { kind: 'lines', widthPx: 9 },
|
|
125
|
+
});
|
|
126
|
+
const changed = preparation.prepare(changedSnapshot, mesh()).unwrap();
|
|
127
|
+
const plan = createPointsLinesRecordPlan(
|
|
128
|
+
changedSnapshot,
|
|
129
|
+
changed.geometry,
|
|
130
|
+
createPointsLinesLaneContract('direct', 'webgpu'),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(uploads).toBe(1);
|
|
134
|
+
expect(changed.geometry).toBe(first.geometry);
|
|
135
|
+
expect(plan.conservativeMarginPx).toBe(4.5);
|
|
136
|
+
expect(changed.geometry.vertices).toEqual(first.geometry.vertices);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('rebuilds from the retained snapshot after device loss without stale bytes', () => {
|
|
140
|
+
const adapter: PointsLinesGpuResourceAdapter<string> = {
|
|
141
|
+
create: () => ok('fresh'),
|
|
142
|
+
upload: () => ok(64),
|
|
143
|
+
validate: () => ok(undefined),
|
|
144
|
+
destroy: () => undefined,
|
|
145
|
+
};
|
|
146
|
+
const preparation = new PointsLinesPreparation({
|
|
147
|
+
cache: new PointsLinesExpansionCache(),
|
|
148
|
+
adapter,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(preparation.prepare(snapshot(), mesh()).ok).toBe(true);
|
|
152
|
+
preparation.resetForDeviceLoss();
|
|
153
|
+
expect(preparation.inspect()).toMatchObject({ status: 'rebuild-pending', liveBytes: 0 });
|
|
154
|
+
expect(preparation.prepare(snapshot(), mesh()).ok).toBe(true);
|
|
155
|
+
expect(preparation.inspect()).toMatchObject({ status: 'resident', lastKnownGood: true });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { type MeshAsset, ok } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { PointsLinesMaterialUnsupportedError } from '../../errors/render';
|
|
4
|
+
import { PointsLinesExpansionCache } from '../expansion-cache';
|
|
5
|
+
import { inspectPointsLines, pointsLinesInspectionToJson } from '../inspection';
|
|
6
|
+
import { type PointsLinesGpuResourceAdapter, PointsLinesPreparation } from '../prepare';
|
|
7
|
+
import { createPointsLinesLaneContract, createPointsLinesRecordPlan } from '../record';
|
|
8
|
+
import { createPointsLinesSnapshot } from '../snapshot';
|
|
9
|
+
|
|
10
|
+
function mesh(): MeshAsset {
|
|
11
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0]);
|
|
12
|
+
return {
|
|
13
|
+
kind: 'mesh',
|
|
14
|
+
vertices: positions,
|
|
15
|
+
indices: new Uint16Array([0, 1, 2, 3]),
|
|
16
|
+
attributes: { position: positions },
|
|
17
|
+
aabb: new Float32Array([0, 0, 0, 1, 1, 0]),
|
|
18
|
+
submeshes: [
|
|
19
|
+
{ indexOffset: 0, indexCount: 4, vertexCount: 4, topology: 'line-list', materialSlot: 0 },
|
|
20
|
+
],
|
|
21
|
+
materialSlots: [{ slotName: 'points-lines' }],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function snapshot(viewport = { width: 320, height: 200, dpr: 1 }) {
|
|
26
|
+
return createPointsLinesSnapshot({
|
|
27
|
+
worldId: 2,
|
|
28
|
+
entityKey: 7,
|
|
29
|
+
component: 'Lines',
|
|
30
|
+
meshHandle: 9,
|
|
31
|
+
meshGeneration: 3,
|
|
32
|
+
materialHandle: 12,
|
|
33
|
+
materialGeneration: 4,
|
|
34
|
+
style: { kind: 'lines', widthPx: 4 },
|
|
35
|
+
layer: 0,
|
|
36
|
+
visible: true,
|
|
37
|
+
sourceBounds: new Float32Array([0, 0, 0, 1, 1, 0]),
|
|
38
|
+
viewport,
|
|
39
|
+
projection: new Float32Array(16),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('Points/Lines inspection and recovery integration', () => {
|
|
44
|
+
it('publishes typed and JSON inspection for each shared lane', () => {
|
|
45
|
+
const geometry = new PointsLinesExpansionCache().getOrCreate(snapshot(), mesh());
|
|
46
|
+
for (const lane of ['direct', 'clustered', 'cpu-webgl2'] as const) {
|
|
47
|
+
const plan = createPointsLinesRecordPlan(
|
|
48
|
+
snapshot(),
|
|
49
|
+
geometry,
|
|
50
|
+
createPointsLinesLaneContract(lane, 'webgpu'),
|
|
51
|
+
);
|
|
52
|
+
const inspection = inspectPointsLines({
|
|
53
|
+
snapshot: snapshot(),
|
|
54
|
+
topology: 'line-list',
|
|
55
|
+
lane,
|
|
56
|
+
pointCount: geometry.pointCount,
|
|
57
|
+
segmentCount: geometry.segmentCount,
|
|
58
|
+
sourceBytes: geometry.sourceBytes,
|
|
59
|
+
derivedBytes: geometry.derivedBytes,
|
|
60
|
+
cache: { hit: false, rebuilds: 1, evictions: 0 },
|
|
61
|
+
drawCount: plan.drawCount,
|
|
62
|
+
uploadBytes: geometry.derivedBytes,
|
|
63
|
+
lastKnownGood: true,
|
|
64
|
+
});
|
|
65
|
+
const json = JSON.parse(pointsLinesInspectionToJson(inspection)) as typeof inspection;
|
|
66
|
+
expect(json).toMatchObject({
|
|
67
|
+
entityKey: 7,
|
|
68
|
+
worldId: 2,
|
|
69
|
+
topology: 'line-list',
|
|
70
|
+
lane,
|
|
71
|
+
segmentCount: 2,
|
|
72
|
+
drawCount: 1,
|
|
73
|
+
lastKnownGood: true,
|
|
74
|
+
});
|
|
75
|
+
expect(json.derivedBytes).toBeGreaterThan(json.sourceBytes);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('keeps the same retained expansion through resize and recovers one LKG', () => {
|
|
80
|
+
const resources: string[] = [];
|
|
81
|
+
const adapter: PointsLinesGpuResourceAdapter<string> = {
|
|
82
|
+
create: () => {
|
|
83
|
+
const resource = `resource-${resources.length + 1}`;
|
|
84
|
+
resources.push(resource);
|
|
85
|
+
return ok(resource);
|
|
86
|
+
},
|
|
87
|
+
upload: () => ok(648),
|
|
88
|
+
validate: () => ok(undefined),
|
|
89
|
+
destroy: (resource) => resources.splice(resources.indexOf(resource), 1),
|
|
90
|
+
};
|
|
91
|
+
const preparation = new PointsLinesPreparation({
|
|
92
|
+
cache: new PointsLinesExpansionCache(),
|
|
93
|
+
adapter,
|
|
94
|
+
});
|
|
95
|
+
const first = preparation.prepare(snapshot(), mesh()).unwrap();
|
|
96
|
+
const resized = preparation
|
|
97
|
+
.prepare(snapshot({ width: 640, height: 360, dpr: 2 }), mesh())
|
|
98
|
+
.unwrap();
|
|
99
|
+
|
|
100
|
+
expect(resized.geometry).toBe(first.geometry);
|
|
101
|
+
expect(preparation.inspect()).toMatchObject({
|
|
102
|
+
status: 'resident',
|
|
103
|
+
lastKnownGood: true,
|
|
104
|
+
rebuilds: 1,
|
|
105
|
+
});
|
|
106
|
+
preparation.resetForDeviceLoss();
|
|
107
|
+
expect(preparation.inspect()).toMatchObject({ status: 'rebuild-pending', liveBytes: 0 });
|
|
108
|
+
preparation.prepare(snapshot({ width: 640, height: 360, dpr: 2 }), mesh()).unwrap();
|
|
109
|
+
expect(preparation.inspect()).toMatchObject({
|
|
110
|
+
status: 'resident',
|
|
111
|
+
lastKnownGood: true,
|
|
112
|
+
rebuilds: 2,
|
|
113
|
+
});
|
|
114
|
+
expect(resources).toEqual(['resource-1']);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('keeps executable refusal hints on the same inspection surface', () => {
|
|
118
|
+
const inspection = inspectPointsLines({
|
|
119
|
+
snapshot: snapshot(),
|
|
120
|
+
topology: 'line-list',
|
|
121
|
+
lane: 'refused',
|
|
122
|
+
pointCount: 0,
|
|
123
|
+
segmentCount: 0,
|
|
124
|
+
sourceBytes: 0,
|
|
125
|
+
derivedBytes: 0,
|
|
126
|
+
cache: { hit: false, rebuilds: 0, evictions: 0 },
|
|
127
|
+
drawCount: 0,
|
|
128
|
+
uploadBytes: 0,
|
|
129
|
+
lastKnownGood: false,
|
|
130
|
+
refusal: {
|
|
131
|
+
...new PointsLinesMaterialUnsupportedError({
|
|
132
|
+
entity: 7,
|
|
133
|
+
material: 'mat',
|
|
134
|
+
pass: 'forward',
|
|
135
|
+
module: 'test',
|
|
136
|
+
reason: 'unsupported test material',
|
|
137
|
+
}),
|
|
138
|
+
generation: 4,
|
|
139
|
+
lastKnownGood: false,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
expect(JSON.parse(pointsLinesInspectionToJson(inspection)).refusal.hint).toContain(
|
|
143
|
+
'Materials.unlit',
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
PointsLinesBudgetExceededError,
|
|
4
|
+
PointsLinesInvalidStyleError,
|
|
5
|
+
PointsLinesMaterialUnsupportedError,
|
|
6
|
+
PointsLinesPrepareFailedError,
|
|
7
|
+
PointsLinesStyleUnsupportedError,
|
|
8
|
+
PointsLinesTopologyMismatchError,
|
|
9
|
+
} from '../../errors/render';
|
|
10
|
+
import {
|
|
11
|
+
inspectPointsLines,
|
|
12
|
+
type PointsLinesInspection,
|
|
13
|
+
pointsLinesInspectionToJson,
|
|
14
|
+
} from '../inspection';
|
|
15
|
+
import { createPointsLinesSnapshot } from '../snapshot';
|
|
16
|
+
|
|
17
|
+
function baseInput(): Parameters<typeof inspectPointsLines>[0] {
|
|
18
|
+
return {
|
|
19
|
+
snapshot: createPointsLinesSnapshot({
|
|
20
|
+
worldId: 2,
|
|
21
|
+
entityKey: 15,
|
|
22
|
+
component: 'Lines',
|
|
23
|
+
meshHandle: 5,
|
|
24
|
+
meshGeneration: 3,
|
|
25
|
+
materialHandle: 7,
|
|
26
|
+
materialGeneration: 4,
|
|
27
|
+
style: { kind: 'lines', widthPx: 2 },
|
|
28
|
+
layer: 1,
|
|
29
|
+
visible: true,
|
|
30
|
+
sourceBounds: new Float32Array([0, 0, 0, 1, 1, 0]),
|
|
31
|
+
viewport: { width: 640, height: 480, dpr: 2 },
|
|
32
|
+
projection: new Float32Array(16),
|
|
33
|
+
}),
|
|
34
|
+
topology: 'line-list',
|
|
35
|
+
lane: 'pending',
|
|
36
|
+
pointCount: 0,
|
|
37
|
+
segmentCount: 2,
|
|
38
|
+
sourceBytes: 64,
|
|
39
|
+
derivedBytes: 48,
|
|
40
|
+
cache: { hit: false, rebuilds: 1, evictions: 0 },
|
|
41
|
+
drawCount: 1,
|
|
42
|
+
uploadBytes: 48,
|
|
43
|
+
lastKnownGood: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('bounded Points/Lines inspection', () => {
|
|
48
|
+
it('projects normal typed facts and a JSON-safe equivalent', () => {
|
|
49
|
+
const inspection = inspectPointsLines(baseInput());
|
|
50
|
+
|
|
51
|
+
expect(inspection).toEqual<PointsLinesInspection>({
|
|
52
|
+
entityKey: 15,
|
|
53
|
+
worldId: 2,
|
|
54
|
+
component: 'Lines',
|
|
55
|
+
meshHandle: 5,
|
|
56
|
+
meshGeneration: 3,
|
|
57
|
+
materialHandle: 7,
|
|
58
|
+
materialGeneration: 4,
|
|
59
|
+
style: { kind: 'lines', widthPx: 2 },
|
|
60
|
+
topology: 'line-list',
|
|
61
|
+
lane: 'pending',
|
|
62
|
+
pointCount: 0,
|
|
63
|
+
segmentCount: 2,
|
|
64
|
+
sourceBytes: 64,
|
|
65
|
+
derivedBytes: 48,
|
|
66
|
+
cache: { hit: false, rebuilds: 1, evictions: 0 },
|
|
67
|
+
drawCount: 1,
|
|
68
|
+
uploadBytes: 48,
|
|
69
|
+
lastKnownGood: true,
|
|
70
|
+
});
|
|
71
|
+
expect(JSON.parse(pointsLinesInspectionToJson(inspection))).toEqual(inspection);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('keeps refusal and recovery provenance bounded and typed', () => {
|
|
75
|
+
const refusal = inspectPointsLines({
|
|
76
|
+
...baseInput(),
|
|
77
|
+
lane: 'refused',
|
|
78
|
+
lastKnownGood: false,
|
|
79
|
+
drawCount: 0,
|
|
80
|
+
uploadBytes: 0,
|
|
81
|
+
refusal: {
|
|
82
|
+
...new PointsLinesPrepareFailedError({
|
|
83
|
+
owner: 'test',
|
|
84
|
+
generation: 3,
|
|
85
|
+
stage: 'prepare',
|
|
86
|
+
cause: 'test failure',
|
|
87
|
+
lastKnownGood: false,
|
|
88
|
+
}),
|
|
89
|
+
generation: 3,
|
|
90
|
+
lastKnownGood: false,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const recovery = inspectPointsLines({
|
|
94
|
+
...baseInput(),
|
|
95
|
+
lane: 'pending',
|
|
96
|
+
cache: { hit: true, rebuilds: 2, evictions: 1 },
|
|
97
|
+
lastKnownGood: true,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(refusal.refusal?.code).toBe('points-lines-prepare-failed');
|
|
101
|
+
expect(refusal.drawCount).toBe(0);
|
|
102
|
+
expect(recovery.cache).toEqual({ hit: true, rebuilds: 2, evictions: 1 });
|
|
103
|
+
expect(pointsLinesInspectionToJson(refusal)).not.toContain('RHI');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('preserves source detail for all six Points/Lines refusal paths', () => {
|
|
107
|
+
const errors = [
|
|
108
|
+
new PointsLinesInvalidStyleError({
|
|
109
|
+
entity: 15,
|
|
110
|
+
component: 'Lines',
|
|
111
|
+
field: 'widthPx',
|
|
112
|
+
value: 0,
|
|
113
|
+
expected: 'widthPx > 0',
|
|
114
|
+
}),
|
|
115
|
+
new PointsLinesTopologyMismatchError({
|
|
116
|
+
entity: 15,
|
|
117
|
+
submesh: 0,
|
|
118
|
+
expected: 'line-list',
|
|
119
|
+
actual: 'triangle-list',
|
|
120
|
+
}),
|
|
121
|
+
new PointsLinesStyleUnsupportedError({
|
|
122
|
+
lane: 'direct',
|
|
123
|
+
field: 'shape',
|
|
124
|
+
member: 'diamond',
|
|
125
|
+
supported: ['square', 'circle'],
|
|
126
|
+
}),
|
|
127
|
+
new PointsLinesMaterialUnsupportedError({
|
|
128
|
+
entity: 15,
|
|
129
|
+
material: 'mat',
|
|
130
|
+
pass: 'forward',
|
|
131
|
+
module: 'test',
|
|
132
|
+
reason: 'unsupported test material',
|
|
133
|
+
}),
|
|
134
|
+
new PointsLinesBudgetExceededError({
|
|
135
|
+
lane: 'direct',
|
|
136
|
+
requested: 9,
|
|
137
|
+
limit: 8,
|
|
138
|
+
unit: 'draws',
|
|
139
|
+
}),
|
|
140
|
+
new PointsLinesPrepareFailedError({
|
|
141
|
+
owner: 'test',
|
|
142
|
+
generation: 3,
|
|
143
|
+
stage: 'prepare',
|
|
144
|
+
cause: 'test failure',
|
|
145
|
+
lastKnownGood: false,
|
|
146
|
+
}),
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
for (const error of errors) {
|
|
150
|
+
const inspection = inspectPointsLines({
|
|
151
|
+
...baseInput(),
|
|
152
|
+
lane: 'refused',
|
|
153
|
+
drawCount: 0,
|
|
154
|
+
uploadBytes: 0,
|
|
155
|
+
lastKnownGood: false,
|
|
156
|
+
refusal: {
|
|
157
|
+
...error,
|
|
158
|
+
generation: 3,
|
|
159
|
+
lastKnownGood: false,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
expect(inspection.refusal).toMatchObject({
|
|
163
|
+
code: error.code,
|
|
164
|
+
expected: error.expected,
|
|
165
|
+
hint: error.hint,
|
|
166
|
+
detail: error.detail,
|
|
167
|
+
});
|
|
168
|
+
expect(JSON.parse(pointsLinesInspectionToJson(inspection)).refusal.detail).toEqual(
|
|
169
|
+
error.detail,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|