@forgeax/engine-render 0.1.19 → 0.1.20
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/dist/.tsbuildinfo +1 -1
- package/dist/assembly/factory.d.ts +16 -5
- package/dist/assembly/factory.d.ts.map +1 -1
- package/dist/{chunk-RJRUS55L.mjs → chunk-4XINDLAY.mjs} +267 -186
- package/dist/chunk-4XINDLAY.mjs.map +1 -0
- package/dist/{chunk-YTQ5C7YQ.mjs → chunk-CDTWTHZR.mjs} +176 -8
- package/dist/chunk-CDTWTHZR.mjs.map +1 -0
- package/dist/{chunk-IPXCABEP.mjs → chunk-M2MP4ZOU.mjs} +2 -2
- package/dist/chunk-M2MP4ZOU.mjs.map +1 -0
- package/dist/{chunk-LOSO4CTV.mjs → chunk-TBQJOK2L.mjs} +67 -3
- package/dist/chunk-TBQJOK2L.mjs.map +1 -0
- package/dist/components/light-helpers.d.ts.map +1 -1
- package/dist/components/spot-light.d.ts +12 -0
- package/dist/components/spot-light.d.ts.map +1 -1
- package/dist/construct-renderer.mjs +2281 -164
- package/dist/construct-renderer.mjs.map +1 -1
- package/dist/device/gpu-residency.d.ts +19 -2
- package/dist/device/gpu-residency.d.ts.map +1 -1
- package/dist/errors/render.d.ts +69 -1
- package/dist/errors/render.d.ts.map +1 -1
- package/dist/gpu-usage.d.ts +2 -0
- package/dist/gpu-usage.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +3 -3
- package/dist/internal.mjs +1 -1
- package/dist/light-buffer-layout.d.ts +20 -12
- package/dist/light-buffer-layout.d.ts.map +1 -1
- package/dist/pbr-pipeline.d.ts +6 -3
- package/dist/pbr-pipeline.d.ts.map +1 -1
- package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
- package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
- package/dist/pipeline/standard-profile.d.ts +13 -0
- package/dist/pipeline/standard-profile.d.ts.map +1 -1
- package/dist/record/frame-lighting.d.ts +2 -0
- package/dist/record/frame-lighting.d.ts.map +1 -1
- package/dist/record/frame-snapshot.d.ts +37 -1
- package/dist/record/frame-snapshot.d.ts.map +1 -1
- package/dist/record/frame-targets.d.ts.map +1 -1
- package/dist/record/frame.d.ts +3 -2
- package/dist/record/frame.d.ts.map +1 -1
- package/dist/record/gpu-timing.d.ts +32 -0
- package/dist/record/gpu-timing.d.ts.map +1 -0
- package/dist/record/main-pass-material.d.ts +1 -1
- package/dist/record/main-pass-material.d.ts.map +1 -1
- package/dist/record/render-context.d.ts +5 -1
- package/dist/record/render-context.d.ts.map +1 -1
- package/dist/record/shadow-pass.d.ts.map +1 -1
- package/dist/record/typed-frame-graph.d.ts +11 -3
- package/dist/record/typed-frame-graph.d.ts.map +1 -1
- package/dist/render-contract.d.ts +66 -1
- package/dist/render-contract.d.ts.map +1 -1
- package/dist/render-data.d.ts +10 -6
- package/dist/render-data.d.ts.map +1 -1
- package/dist/render-pipeline.d.ts +36 -0
- package/dist/render-pipeline.d.ts.map +1 -1
- package/dist/render-system-extract.d.ts +55 -3
- package/dist/render-system-extract.d.ts.map +1 -1
- package/dist/render-system.d.ts +7 -1
- package/dist/render-system.d.ts.map +1 -1
- package/dist/renderer-inspect.d.ts +29 -0
- package/dist/renderer-inspect.d.ts.map +1 -0
- package/dist/scene/render-scene.d.ts.map +1 -1
- package/dist/temporal/frame.d.ts +44 -0
- package/dist/temporal/frame.d.ts.map +1 -0
- package/dist/temporal/index.mjs +3 -3
- package/dist/typed-render-graph-primitives.d.ts +6 -1
- package/dist/typed-render-graph-primitives.d.ts.map +1 -1
- package/dist/typed-shadow-atlas.d.ts +10 -0
- package/dist/typed-shadow-atlas.d.ts.map +1 -0
- package/dist/typed-shadow-passes.d.ts.map +1 -1
- package/dist/volume/capability.d.ts +57 -0
- package/dist/volume/capability.d.ts.map +1 -0
- package/dist/volume/component.d.ts +61 -0
- package/dist/volume/component.d.ts.map +1 -0
- package/dist/volume/extract.d.ts +11 -0
- package/dist/volume/extract.d.ts.map +1 -0
- package/dist/volume/inspection.d.ts +38 -0
- package/dist/volume/inspection.d.ts.map +1 -0
- package/dist/volume/passes.d.ts +38 -0
- package/dist/volume/passes.d.ts.map +1 -0
- package/dist/volume/recovery.d.ts +38 -0
- package/dist/volume/recovery.d.ts.map +1 -0
- package/dist/volume/resources.d.ts +57 -0
- package/dist/volume/resources.d.ts.map +1 -0
- package/dist/volume/temporal.d.ts +35 -0
- package/dist/volume/temporal.d.ts.map +1 -0
- package/package.json +20 -20
- package/src/__tests__/ktx2-basis-gpu-consumer.dawn.test.ts +14 -14
- package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +1 -1
- package/src/__tests__/render-error-code-owner.test-d.ts +5 -0
- package/src/__tests__/renderer-factory-material-contract.unit.test.ts +28 -0
- package/src/__tests__/renderer-frame-transaction.integration.test.ts +1 -1
- package/src/__tests__/skinned-shadow-caster.test.ts +21 -0
- package/src/__tests__/standard-profile-oracle.unit.test.ts +7 -5
- package/src/__tests__/volume-optics-oracle.unit.test.ts +85 -0
- package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +655 -0
- package/src/__tests__/volumetric-fog-component.unit.test.ts +71 -0
- package/src/__tests__/volumetric-fog-composite-stage-readback.dawn.test.ts +376 -0
- package/src/__tests__/volumetric-fog-errors.unit.test.ts +51 -0
- package/src/__tests__/volumetric-fog-inspection.unit.test.ts +77 -0
- package/src/__tests__/volumetric-fog-light-capability.unit.test.ts +19 -0
- package/src/__tests__/volumetric-fog-light-consumers.integration.test.ts +18 -0
- package/src/__tests__/volumetric-fog-light-errors.unit.test.ts +17 -0
- package/src/__tests__/volumetric-fog-light-topology.unit.test.ts +21 -0
- package/src/__tests__/volumetric-fog-light-transaction.integration.test.ts +53 -0
- package/src/__tests__/volumetric-fog-m9-watcher.integration.test.ts +27 -0
- package/src/__tests__/volumetric-fog-point-light-owner.integration.test.ts +44 -0
- package/src/__tests__/volumetric-fog-point-spot-accumulation.integration.test.ts +49 -0
- package/src/__tests__/volumetric-fog-point-spot-contract.unit.test.ts +28 -0
- package/src/__tests__/volumetric-fog-point-spot-lkg.integration.test.ts +65 -0
- package/src/__tests__/volumetric-fog-point-spot-selection.integration.test.ts +49 -0
- package/src/__tests__/volumetric-fog-point-spot-temporal.unit.test.ts +55 -0
- package/src/__tests__/volumetric-fog-projector-contract.unit.test.ts +22 -0
- package/src/__tests__/volumetric-fog-projector-inspection.unit.test.ts +12 -0
- package/src/__tests__/volumetric-fog-projector-surface-volume.integration.test.ts +11 -0
- package/src/__tests__/volumetric-fog-projector-transaction.integration.test.ts +26 -0
- package/src/__tests__/volumetric-fog-recovery.integration.test.ts +47 -0
- package/src/__tests__/volumetric-fog-selected-light-resolution.integration.test.ts +63 -0
- package/src/__tests__/volumetric-fog-spot-closure.integration.test.ts +26 -0
- package/src/__tests__/volumetric-fog-spot-contract.test-d.ts +21 -0
- package/src/__tests__/volumetric-fog-spot-contract.unit.test.ts +27 -0
- package/src/__tests__/volumetric-fog-spot-frame-optics.integration.test.ts +14 -0
- package/src/__tests__/volumetric-fog-spot-optics.integration.test.ts +29 -0
- package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +550 -0
- package/src/__tests__/volumetric-fog-temporal-selected-light.unit.test.ts +33 -0
- package/src/__tests__/volumetric-fog-temporal.unit.test.ts +45 -0
- package/src/__tests__/volumetric-fog-topology.unit.test.ts +237 -0
- package/src/__tests__/volumetric-fog-transparent.integration.test.ts +27 -0
- package/src/assembly/factory.ts +271 -15
- package/src/components/light-helpers.ts +4 -0
- package/src/components/spot-light.ts +22 -3
- package/src/device/gpu-residency.ts +168 -58
- package/src/errors/render.ts +119 -1
- package/src/gpu-usage.ts +2 -0
- package/src/index.ts +38 -1
- package/src/light-buffer-layout.ts +59 -26
- package/src/pbr-pipeline.ts +33 -10
- package/src/pipeline/standard-forward-lane.ts +20 -0
- package/src/pipeline/standard-pipeline.ts +20 -0
- package/src/pipeline/standard-profile.ts +23 -0
- package/src/record/frame-lighting.ts +103 -89
- package/src/record/frame-snapshot.ts +41 -1
- package/src/record/frame-targets.ts +6 -2
- package/src/record/frame.ts +908 -5
- package/src/record/gpu-timing.ts +224 -0
- package/src/record/main-pass-material.ts +6 -2
- package/src/record/render-context.ts +5 -0
- package/src/record/shadow-pass.ts +87 -38
- package/src/record/typed-frame-graph.ts +143 -8
- package/src/render-contract.ts +72 -0
- package/src/render-data.ts +50 -22
- package/src/render-pipeline.ts +44 -0
- package/src/render-system-extract.ts +329 -3
- package/src/render-system.ts +79 -0
- package/src/renderer-inspect.ts +66 -0
- package/src/scene/render-scene.ts +2 -0
- package/src/temporal/frame.ts +128 -0
- package/src/transmission/__tests__/standard-transmission.dawn.test.ts +2 -3
- package/src/typed-render-graph-primitives.ts +32 -2
- package/src/typed-shadow-atlas.ts +17 -0
- package/src/typed-shadow-passes.ts +6 -5
- package/src/volume/capability.ts +173 -0
- package/src/volume/component.ts +110 -0
- package/src/volume/extract.ts +21 -0
- package/src/volume/inspection.ts +78 -0
- package/src/volume/passes.ts +680 -0
- package/src/volume/recovery.ts +86 -0
- package/src/volume/resources.ts +236 -0
- package/src/volume/temporal.ts +89 -0
- package/dist/chunk-IPXCABEP.mjs.map +0 -1
- package/dist/chunk-LOSO4CTV.mjs.map +0 -1
- package/dist/chunk-RJRUS55L.mjs.map +0 -1
- package/dist/chunk-YTQ5C7YQ.mjs.map +0 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { TextureFormat } from '@forgeax/engine-rhi';
|
|
2
|
+
import type { TemporalFrame, TemporalResetReason } from './temporal/frame';
|
|
3
|
+
|
|
4
|
+
export type TemporalInspectionStatus =
|
|
5
|
+
| 'authored-off'
|
|
6
|
+
| 'available'
|
|
7
|
+
| 'capability-unavailable'
|
|
8
|
+
| 'temporal-degraded';
|
|
9
|
+
|
|
10
|
+
export interface TemporalInspection {
|
|
11
|
+
readonly status: TemporalInspectionStatus;
|
|
12
|
+
readonly resetReason: TemporalResetReason;
|
|
13
|
+
readonly historyEpoch: number;
|
|
14
|
+
readonly deviceEpoch: number;
|
|
15
|
+
readonly backend: string | undefined;
|
|
16
|
+
readonly format: TextureFormat | undefined;
|
|
17
|
+
readonly limits: Readonly<{
|
|
18
|
+
readonly maxTextureDimension2D?: number;
|
|
19
|
+
readonly maxTextureDimension3D?: number;
|
|
20
|
+
readonly maxTextureArrayLayers?: number;
|
|
21
|
+
}>;
|
|
22
|
+
readonly compute: 'available' | 'unavailable';
|
|
23
|
+
readonly storage: 'available' | 'unavailable';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TemporalInspectionInput {
|
|
27
|
+
readonly authored: boolean;
|
|
28
|
+
readonly capability: 'available' | 'unavailable';
|
|
29
|
+
readonly degraded?: boolean;
|
|
30
|
+
readonly frame?: TemporalFrame;
|
|
31
|
+
readonly backend?: string;
|
|
32
|
+
readonly format?: TextureFormat;
|
|
33
|
+
readonly limits?: Readonly<Record<string, number>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function inspectTemporalFrame(input: TemporalInspectionInput): TemporalInspection {
|
|
37
|
+
const frame = input.frame;
|
|
38
|
+
const status: TemporalInspectionStatus = !input.authored
|
|
39
|
+
? 'authored-off'
|
|
40
|
+
: input.capability === 'unavailable'
|
|
41
|
+
? 'capability-unavailable'
|
|
42
|
+
: input.degraded === true
|
|
43
|
+
? 'temporal-degraded'
|
|
44
|
+
: 'available';
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
status,
|
|
47
|
+
resetReason: frame?.resetReason ?? 'none',
|
|
48
|
+
historyEpoch: frame?.historyEpoch ?? 0,
|
|
49
|
+
deviceEpoch: frame?.deviceEpoch ?? 0,
|
|
50
|
+
backend: input.backend,
|
|
51
|
+
format: input.format,
|
|
52
|
+
limits: Object.freeze({
|
|
53
|
+
...(typeof input.limits?.maxTextureDimension2D === 'number'
|
|
54
|
+
? { maxTextureDimension2D: input.limits.maxTextureDimension2D }
|
|
55
|
+
: {}),
|
|
56
|
+
...(typeof input.limits?.maxTextureDimension3D === 'number'
|
|
57
|
+
? { maxTextureDimension3D: input.limits.maxTextureDimension3D }
|
|
58
|
+
: {}),
|
|
59
|
+
...(typeof input.limits?.maxTextureArrayLayers === 'number'
|
|
60
|
+
? { maxTextureArrayLayers: input.limits.maxTextureArrayLayers }
|
|
61
|
+
: {}),
|
|
62
|
+
}),
|
|
63
|
+
compute: input.capability === 'available' ? 'available' : 'unavailable',
|
|
64
|
+
storage: input.capability === 'available' ? 'available' : 'unavailable',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
type TransmissionDemand,
|
|
48
48
|
type TransmissionDemandOperation,
|
|
49
49
|
} from '../transmission/projection';
|
|
50
|
+
import { VolumetricFog } from '../volume/component';
|
|
50
51
|
import type {
|
|
51
52
|
RenderSceneApplyResult,
|
|
52
53
|
RenderSceneBounds,
|
|
@@ -761,6 +762,7 @@ const RENDER_RELEVANT_COMPONENT_IDS = new Set([
|
|
|
761
762
|
componentId(Skylight),
|
|
762
763
|
componentId(SkyboxBackground),
|
|
763
764
|
componentId(PostProcessParams),
|
|
765
|
+
componentId(VolumetricFog),
|
|
764
766
|
]);
|
|
765
767
|
|
|
766
768
|
const NON_RENDERABLE_TRANSFORM_CONSUMERS = [
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
import { TemporalFrameSubmitError } from '../errors/render';
|
|
3
|
+
|
|
4
|
+
export type TemporalResetReason =
|
|
5
|
+
| 'none'
|
|
6
|
+
| 'camera-cut'
|
|
7
|
+
| 'resize'
|
|
8
|
+
| 'signature-change'
|
|
9
|
+
| 'depth-discontinuity'
|
|
10
|
+
| 'out-of-screen'
|
|
11
|
+
| 'queue-submit-failed'
|
|
12
|
+
| 'device-recovery';
|
|
13
|
+
|
|
14
|
+
export interface TemporalFrameInput {
|
|
15
|
+
readonly frameId: number;
|
|
16
|
+
readonly currentViewProjection: Float32Array;
|
|
17
|
+
readonly jitter: readonly [number, number];
|
|
18
|
+
readonly viewport: { readonly width: number; readonly height: number };
|
|
19
|
+
/** Current camera origin, retained only as accepted temporal history. */
|
|
20
|
+
readonly cameraPosition?: readonly [number, number, number];
|
|
21
|
+
readonly resetReason?: Exclude<TemporalResetReason, 'none' | 'queue-submit-failed'>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TemporalFrame {
|
|
25
|
+
readonly frameId: number;
|
|
26
|
+
readonly currentViewProjection: Float32Array;
|
|
27
|
+
readonly previousViewProjection: Float32Array | undefined;
|
|
28
|
+
readonly jitter: readonly [number, number];
|
|
29
|
+
readonly viewport: { readonly width: number; readonly height: number };
|
|
30
|
+
readonly currentCameraPosition: readonly [number, number, number];
|
|
31
|
+
readonly previousCameraPosition: readonly [number, number, number] | undefined;
|
|
32
|
+
readonly resetReason: TemporalResetReason;
|
|
33
|
+
readonly historyEpoch: number;
|
|
34
|
+
readonly deviceEpoch: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TemporalSubmitResult {
|
|
38
|
+
readonly accepted: boolean;
|
|
39
|
+
readonly reason?: 'queue-submit-failed';
|
|
40
|
+
readonly resetReason?: Exclude<TemporalResetReason, 'none' | 'queue-submit-failed'>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TemporalFrameTransaction {
|
|
44
|
+
stage(input: TemporalFrameInput): void;
|
|
45
|
+
commit(submit: TemporalSubmitResult): Result<TemporalFrame, TemporalFrameSubmitError>;
|
|
46
|
+
snapshot(): TemporalFrame;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function copyInput(input: TemporalFrameInput): TemporalFrameInput {
|
|
50
|
+
return {
|
|
51
|
+
...input,
|
|
52
|
+
currentViewProjection: new Float32Array(input.currentViewProjection),
|
|
53
|
+
jitter: [input.jitter[0], input.jitter[1]],
|
|
54
|
+
viewport: { ...input.viewport },
|
|
55
|
+
...(input.cameraPosition === undefined
|
|
56
|
+
? {}
|
|
57
|
+
: { cameraPosition: [...input.cameraPosition] as [number, number, number] }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function copyFrame(frame: TemporalFrame): TemporalFrame {
|
|
62
|
+
return {
|
|
63
|
+
...frame,
|
|
64
|
+
currentViewProjection: new Float32Array(frame.currentViewProjection),
|
|
65
|
+
previousViewProjection:
|
|
66
|
+
frame.previousViewProjection === undefined
|
|
67
|
+
? undefined
|
|
68
|
+
: new Float32Array(frame.previousViewProjection),
|
|
69
|
+
jitter: [frame.jitter[0], frame.jitter[1]],
|
|
70
|
+
viewport: { ...frame.viewport },
|
|
71
|
+
currentCameraPosition: [...frame.currentCameraPosition] as [number, number, number],
|
|
72
|
+
previousCameraPosition:
|
|
73
|
+
frame.previousCameraPosition === undefined
|
|
74
|
+
? undefined
|
|
75
|
+
: ([...frame.previousCameraPosition] as [number, number, number]),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createTemporalFrameTransaction(options: {
|
|
80
|
+
readonly deviceEpoch: number;
|
|
81
|
+
}): TemporalFrameTransaction {
|
|
82
|
+
let accepted: TemporalFrame | undefined;
|
|
83
|
+
let staged: TemporalFrameInput | undefined;
|
|
84
|
+
let historyEpoch = 0;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
stage(input) {
|
|
88
|
+
staged = copyInput(input);
|
|
89
|
+
},
|
|
90
|
+
commit(submit) {
|
|
91
|
+
if (!submit.accepted) {
|
|
92
|
+
staged = undefined;
|
|
93
|
+
return err(new TemporalFrameSubmitError(submit.reason ?? 'queue-submit-failed'));
|
|
94
|
+
}
|
|
95
|
+
if (staged === undefined) {
|
|
96
|
+
return err(new TemporalFrameSubmitError('queue-submit-failed'));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const resetReason = submit.resetReason ?? staged.resetReason ?? 'none';
|
|
100
|
+
const shouldReset = resetReason !== 'none';
|
|
101
|
+
if (shouldReset) historyEpoch += 1;
|
|
102
|
+
const next: TemporalFrame = {
|
|
103
|
+
frameId: staged.frameId,
|
|
104
|
+
currentViewProjection: new Float32Array(staged.currentViewProjection),
|
|
105
|
+
previousViewProjection:
|
|
106
|
+
accepted === undefined || shouldReset
|
|
107
|
+
? undefined
|
|
108
|
+
: new Float32Array(accepted.currentViewProjection),
|
|
109
|
+
jitter: [staged.jitter[0], staged.jitter[1]],
|
|
110
|
+
viewport: { ...staged.viewport },
|
|
111
|
+
currentCameraPosition: staged.cameraPosition ?? [0, 0, 0],
|
|
112
|
+
previousCameraPosition:
|
|
113
|
+
accepted === undefined || shouldReset ? undefined : accepted.currentCameraPosition,
|
|
114
|
+
resetReason,
|
|
115
|
+
historyEpoch,
|
|
116
|
+
deviceEpoch: options.deviceEpoch,
|
|
117
|
+
};
|
|
118
|
+
accepted = next;
|
|
119
|
+
staged = undefined;
|
|
120
|
+
return ok(copyFrame(next));
|
|
121
|
+
},
|
|
122
|
+
snapshot() {
|
|
123
|
+
if (accepted === undefined)
|
|
124
|
+
throw new Error('TemporalFrame snapshot is unavailable before commit');
|
|
125
|
+
return copyFrame(accepted);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -65,12 +65,11 @@ function textureAsset(value: number): TextureAsset {
|
|
|
65
65
|
const channel = Math.round(value * 255);
|
|
66
66
|
return {
|
|
67
67
|
kind: 'texture',
|
|
68
|
-
width: 1,
|
|
69
|
-
height: 1,
|
|
68
|
+
shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
|
|
70
69
|
format: 'rgba8unorm',
|
|
71
70
|
data: Uint8Array.from([channel, channel, channel, 255]),
|
|
72
71
|
colorSpace: 'linear',
|
|
73
|
-
|
|
72
|
+
mips: { kind: 'none' },
|
|
74
73
|
};
|
|
75
74
|
}
|
|
76
75
|
|
|
@@ -215,7 +215,13 @@ export function addTypedScenePass(
|
|
|
215
215
|
internal.pipelineState,
|
|
216
216
|
internal.validated.length > 0 || options.gpuDriven !== undefined,
|
|
217
217
|
internal.bindGroupCounts,
|
|
218
|
-
{
|
|
218
|
+
{
|
|
219
|
+
directionalShadow,
|
|
220
|
+
spotShadow,
|
|
221
|
+
projector: internal.spotLightProjector?.view ?? internal.volumetricFog?.projectorView,
|
|
222
|
+
projectorSampler:
|
|
223
|
+
internal.spotLightProjector?.sampler ?? internal.volumetricFog?.projectorSampler,
|
|
224
|
+
},
|
|
219
225
|
);
|
|
220
226
|
if (groups.viewBindGroup !== null) {
|
|
221
227
|
options.gpuDriven?.encode(groups.viewBindGroup, pass, resources);
|
|
@@ -461,6 +467,10 @@ export interface TypedFullscreenPassOptions {
|
|
|
461
467
|
readonly key: string;
|
|
462
468
|
readonly target: RenderPipelineTarget;
|
|
463
469
|
}[];
|
|
470
|
+
/** Optional per-pass copy of the params UBO payload. */
|
|
471
|
+
readonly paramsTransform?:
|
|
472
|
+
| ((params: Uint8Array | undefined) => Uint8Array | undefined)
|
|
473
|
+
| undefined;
|
|
464
474
|
}
|
|
465
475
|
|
|
466
476
|
export interface TypedTemporalResolveTargets {
|
|
@@ -615,6 +625,11 @@ export function addTypedFullscreenPass(
|
|
|
615
625
|
...(options.rawSwapchainOutput === undefined
|
|
616
626
|
? {}
|
|
617
627
|
: { rawSwapchainOutput: options.rawSwapchainOutput }),
|
|
628
|
+
...(options.paramsTransform === undefined
|
|
629
|
+
? {}
|
|
630
|
+
: {
|
|
631
|
+
paramsOverride: options.paramsTransform(frame.postProcessParams.get(options.shader)),
|
|
632
|
+
}),
|
|
618
633
|
});
|
|
619
634
|
},
|
|
620
635
|
});
|
|
@@ -681,8 +696,9 @@ export function addTypedOutputTransformPass(
|
|
|
681
696
|
graph: RenderGraphBuilder<RenderPipelineFrame>,
|
|
682
697
|
input: RenderPipelineTarget,
|
|
683
698
|
output: RenderPipelineTarget,
|
|
684
|
-
outputOnly =
|
|
699
|
+
options: { readonly outputOnly?: boolean; readonly outputTransform?: boolean } = {},
|
|
685
700
|
): Result<void, RenderGraphError> {
|
|
701
|
+
const outputOnly = options.outputOnly ?? false;
|
|
686
702
|
return addTypedFullscreenPass(graph, {
|
|
687
703
|
name: outputOnly ? 'present' : 'output-transform',
|
|
688
704
|
shader: STANDARD_OUTPUT_TRANSFORM_FEATURE_ID,
|
|
@@ -690,5 +706,19 @@ export function addTypedOutputTransformPass(
|
|
|
690
706
|
output,
|
|
691
707
|
outputOnly,
|
|
692
708
|
rawSwapchainOutput: outputOnly,
|
|
709
|
+
...(options.outputTransform === true
|
|
710
|
+
? {
|
|
711
|
+
paramsTransform: (params: Uint8Array | undefined): Uint8Array | undefined => {
|
|
712
|
+
if (params === undefined || params.byteLength !== 16) return params;
|
|
713
|
+
const transformed = params.slice();
|
|
714
|
+
new DataView(
|
|
715
|
+
transformed.buffer,
|
|
716
|
+
transformed.byteOffset,
|
|
717
|
+
transformed.byteLength,
|
|
718
|
+
).setFloat32(12, 1, true);
|
|
719
|
+
return transformed;
|
|
720
|
+
},
|
|
721
|
+
}
|
|
722
|
+
: {}),
|
|
693
723
|
});
|
|
694
724
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface DirectionalShadowAtlasGrid {
|
|
2
|
+
readonly columns: 1 | 2;
|
|
3
|
+
readonly rows: 1 | 2;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The directional CSM atlas layout is one compact contract for raster writes
|
|
8
|
+
* and shader reads: 1 -> 1x1, 2 -> 2x1, and 3/4 -> 2x2.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveDirectionalShadowAtlasGrid(
|
|
11
|
+
cascadeCount: number,
|
|
12
|
+
): DirectionalShadowAtlasGrid {
|
|
13
|
+
const count = Math.max(1, Math.min(4, Math.round(cascadeCount)));
|
|
14
|
+
if (count === 1) return { columns: 1, rows: 1 };
|
|
15
|
+
if (count === 2) return { columns: 2, rows: 1 };
|
|
16
|
+
return { columns: 2, rows: 2 };
|
|
17
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from './record/shadow-pass';
|
|
11
11
|
import type { RenderPipelineFrame, RenderPipelineTopology } from './render-pipeline';
|
|
12
12
|
import { createRenderPipelineTarget, type RenderPipelineTarget } from './render-pipeline';
|
|
13
|
+
import { resolveDirectionalShadowAtlasGrid } from './typed-shadow-atlas';
|
|
13
14
|
|
|
14
15
|
export interface TypedShadowTargets {
|
|
15
16
|
readonly directional: RenderPipelineTarget;
|
|
@@ -43,18 +44,18 @@ export function addTypedShadowPasses(
|
|
|
43
44
|
graph: RenderGraphBuilder<RenderPipelineFrame>,
|
|
44
45
|
topology: RenderPipelineTopology,
|
|
45
46
|
): Result<TypedShadowTargets, RenderGraphError> {
|
|
46
|
-
const
|
|
47
|
+
const atlasGrid = resolveDirectionalShadowAtlasGrid(topology.shadow.cascadeCount);
|
|
47
48
|
const directional = createRenderPipelineTarget(graph, 'directional-shadow-depth', {
|
|
48
49
|
format: 'depth32float',
|
|
49
50
|
size: {
|
|
50
|
-
width: topology.shadow.mapSize *
|
|
51
|
-
height: topology.shadow.mapSize *
|
|
51
|
+
width: topology.shadow.mapSize * atlasGrid.columns,
|
|
52
|
+
height: topology.shadow.mapSize * atlasGrid.rows,
|
|
52
53
|
},
|
|
53
54
|
});
|
|
54
55
|
if (!directional.ok) return directional;
|
|
55
56
|
for (let cascade = 0; cascade < topology.shadow.cascadeCount; cascade += 1) {
|
|
56
|
-
const col = cascade %
|
|
57
|
-
const row = Math.floor(cascade /
|
|
57
|
+
const col = cascade % atlasGrid.columns;
|
|
58
|
+
const row = Math.floor(cascade / atlasGrid.columns);
|
|
58
59
|
const added = recordShadowPass(
|
|
59
60
|
graph,
|
|
60
61
|
`shadowCascade${cascade}`,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Entity, type EntityHandle, type World } from '@forgeax/engine-ecs';
|
|
2
|
+
import type { RhiCaps } from '@forgeax/engine-rhi';
|
|
3
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
4
|
+
import { DirectionalLight } from '../components/directional-light';
|
|
5
|
+
import { PointLight } from '../components/point-light';
|
|
6
|
+
import { SpotLight } from '../components/spot-light';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The single capability predicate for the authored volumetric-fog producer.
|
|
10
|
+
* Shader sources are part of the device-bound bundle: compute/storage alone
|
|
11
|
+
* is not enough to claim that the three fused graph passes can execute.
|
|
12
|
+
*/
|
|
13
|
+
export function hasVolumetricFogCapability(
|
|
14
|
+
caps: Pick<RhiCaps, 'compute' | 'storageTexture'>,
|
|
15
|
+
shaders: unknown,
|
|
16
|
+
): boolean {
|
|
17
|
+
return caps.compute && caps.storageTexture && shaders !== undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type VolumetricFogLightKind = 'directional' | 'point' | 'spot';
|
|
21
|
+
// The exact parity authoring mode is the closed `point-spot` pair.
|
|
22
|
+
|
|
23
|
+
export type VolumetricFogLightResolution =
|
|
24
|
+
| {
|
|
25
|
+
readonly status: 'available';
|
|
26
|
+
readonly entity: EntityHandle;
|
|
27
|
+
readonly kind: VolumetricFogLightKind;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
readonly status: 'unresolved';
|
|
31
|
+
readonly reason: 'missing-entity' | 'wrong-component' | 'ambiguous';
|
|
32
|
+
readonly entity: EntityHandle;
|
|
33
|
+
readonly expected: 'DirectionalLight | PointLight | SpotLight';
|
|
34
|
+
readonly actual: string;
|
|
35
|
+
readonly hint: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type VolumetricFogLightPairResolution =
|
|
39
|
+
| {
|
|
40
|
+
readonly status: 'available';
|
|
41
|
+
readonly mode: 'point-spot';
|
|
42
|
+
readonly point: EntityHandle;
|
|
43
|
+
readonly spot: EntityHandle;
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
readonly status: 'unresolved';
|
|
47
|
+
readonly reason: 'missing-entity' | 'same-entity' | 'wrong-component';
|
|
48
|
+
readonly point: EntityHandle;
|
|
49
|
+
readonly spot: EntityHandle;
|
|
50
|
+
readonly expected: 'PointLight + SpotLight in the same World';
|
|
51
|
+
readonly actual: string;
|
|
52
|
+
readonly hint: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Resolve the narrow official Point+Spot pair without creating a light owner. */
|
|
56
|
+
export function resolveVolumetricFogLightPair(
|
|
57
|
+
world: World,
|
|
58
|
+
point: EntityHandle,
|
|
59
|
+
spot: EntityHandle,
|
|
60
|
+
): VolumetricFogLightPairResolution {
|
|
61
|
+
const expected = 'PointLight + SpotLight in the same World' as const;
|
|
62
|
+
if (!world.get(point, Entity).ok || !world.get(spot, Entity).ok) {
|
|
63
|
+
return {
|
|
64
|
+
status: 'unresolved',
|
|
65
|
+
reason: 'missing-entity',
|
|
66
|
+
point,
|
|
67
|
+
spot,
|
|
68
|
+
expected,
|
|
69
|
+
actual: 'point or spot entity is absent from this World',
|
|
70
|
+
hint: 'assign two live entities from the same World and retry',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (point === spot) {
|
|
74
|
+
return {
|
|
75
|
+
status: 'unresolved',
|
|
76
|
+
reason: 'same-entity',
|
|
77
|
+
point,
|
|
78
|
+
spot,
|
|
79
|
+
expected,
|
|
80
|
+
actual: 'PointLight and SpotLight share one EntityHandle',
|
|
81
|
+
hint: 'spawn PointLight and SpotLight on two different entities',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const pointValid = world.hasComponent(point, PointLight) && world.hasComponent(point, Transform);
|
|
85
|
+
const spotValid = world.hasComponent(spot, SpotLight) && world.hasComponent(spot, Transform);
|
|
86
|
+
if (!pointValid || !spotValid) {
|
|
87
|
+
return {
|
|
88
|
+
status: 'unresolved',
|
|
89
|
+
reason: 'wrong-component',
|
|
90
|
+
point,
|
|
91
|
+
spot,
|
|
92
|
+
expected,
|
|
93
|
+
actual: `pointValid=${pointValid}; spotValid=${spotValid}`,
|
|
94
|
+
hint: 'attach PointLight and SpotLight with Transform to separate same-World entities',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return { status: 'available', mode: 'point-spot', point, spot };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the required VolumetricFog.light reference in its owning World.
|
|
102
|
+
* PointLight is a valid primary selection for the exact Point+Spot pair.
|
|
103
|
+
* EntityHandle has no portable world tag, so a handle absent from this World
|
|
104
|
+
* (the same World contract) is reported as missing-entity;
|
|
105
|
+
* is reported as missing-entity; callers must not manufacture a light.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveSelectedVolumetricLight(
|
|
108
|
+
world: World,
|
|
109
|
+
entity: EntityHandle,
|
|
110
|
+
): VolumetricFogLightResolution {
|
|
111
|
+
const expected = 'DirectionalLight | PointLight | SpotLight' as const;
|
|
112
|
+
if (!world.get(entity, Entity).ok) {
|
|
113
|
+
return {
|
|
114
|
+
status: 'unresolved',
|
|
115
|
+
reason: 'missing-entity',
|
|
116
|
+
entity,
|
|
117
|
+
expected,
|
|
118
|
+
actual: 'missing or foreign World entity',
|
|
119
|
+
hint: 'assign VolumetricFog.light to a live DirectionalLight or SpotLight in this World',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const hasDirectional = world.hasComponent(entity, DirectionalLight);
|
|
123
|
+
const hasSpot = world.hasComponent(entity, SpotLight);
|
|
124
|
+
if (hasDirectional && hasSpot) {
|
|
125
|
+
return {
|
|
126
|
+
status: 'unresolved',
|
|
127
|
+
reason: 'ambiguous',
|
|
128
|
+
entity,
|
|
129
|
+
expected,
|
|
130
|
+
actual: 'DirectionalLight + SpotLight',
|
|
131
|
+
hint: 'remove one mutually exclusive light component from the selected entity',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (hasDirectional) return { status: 'available', entity, kind: 'directional' };
|
|
135
|
+
if (world.hasComponent(entity, PointLight) && world.hasComponent(entity, Transform)) {
|
|
136
|
+
return { status: 'available', entity, kind: 'point' };
|
|
137
|
+
}
|
|
138
|
+
if (hasSpot && world.hasComponent(entity, Transform)) {
|
|
139
|
+
return { status: 'available', entity, kind: 'spot' };
|
|
140
|
+
}
|
|
141
|
+
const actual = hasSpot
|
|
142
|
+
? 'SpotLight without Transform'
|
|
143
|
+
: world.hasComponent(entity, PointLight)
|
|
144
|
+
? 'PointLight'
|
|
145
|
+
: 'other component';
|
|
146
|
+
return {
|
|
147
|
+
status: 'unresolved',
|
|
148
|
+
reason: 'wrong-component',
|
|
149
|
+
entity,
|
|
150
|
+
expected,
|
|
151
|
+
actual,
|
|
152
|
+
hint: 'add exactly one supported light component and a Transform for PointLight or SpotLight in the same World',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface IntegratedVolumeResource {
|
|
157
|
+
/** Logical resolved-volume identity; this is not a physical graph resource. */
|
|
158
|
+
readonly identity: 'volume-integrated';
|
|
159
|
+
readonly format: 'rgba16float';
|
|
160
|
+
readonly stage: 'resolved';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type VolumeConsumer = 'opaque' | 'transparent' | 'sprite' | 'vfx' | 'custom';
|
|
164
|
+
|
|
165
|
+
export function resolveIntegratedVolumeConsumer(
|
|
166
|
+
resource: IntegratedVolumeResource,
|
|
167
|
+
consumer: VolumeConsumer,
|
|
168
|
+
): { readonly consumer: VolumeConsumer; readonly resource: IntegratedVolumeResource } {
|
|
169
|
+
// The volume owner supplies this prepared logical resource. Consumers only
|
|
170
|
+
// receive its identity and format; they never allocate a view or own the
|
|
171
|
+
// history lifetime here.
|
|
172
|
+
return { consumer, resource };
|
|
173
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { defineComponent, type EntityHandle } from '@forgeax/engine-ecs';
|
|
2
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
3
|
+
import {
|
|
4
|
+
VolumeDensityShapeMismatchError,
|
|
5
|
+
type VolumeError,
|
|
6
|
+
VolumeInvalidBoundsError,
|
|
7
|
+
VolumeInvalidParametersError,
|
|
8
|
+
VolumeOwnerConflictError,
|
|
9
|
+
} from '../errors/render';
|
|
10
|
+
|
|
11
|
+
export interface VolumeDensityBinding {
|
|
12
|
+
readonly guid: string;
|
|
13
|
+
readonly generation: number;
|
|
14
|
+
readonly shape: {
|
|
15
|
+
readonly viewDimension: '2d' | '2d-array' | '3d';
|
|
16
|
+
readonly extent: {
|
|
17
|
+
readonly width: number;
|
|
18
|
+
readonly height: number;
|
|
19
|
+
readonly depth?: number;
|
|
20
|
+
readonly layers?: number;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
readonly format: string;
|
|
24
|
+
readonly colorSpace?: 'linear' | 'srgb';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface VolumeBounds {
|
|
28
|
+
readonly min: readonly [number, number, number];
|
|
29
|
+
readonly max: readonly [number, number, number];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface VolumetricFogAuthoring {
|
|
33
|
+
/** Invalid authoring reports closed code, expected, hint, and detail fields. */
|
|
34
|
+
/** The selected DirectionalLight or PointLight in this World. */
|
|
35
|
+
readonly light: EntityHandle;
|
|
36
|
+
/** The optional same-World SpotLight paired with a PointLight. */
|
|
37
|
+
readonly spotLight?: EntityHandle;
|
|
38
|
+
readonly density: VolumeDensityBinding;
|
|
39
|
+
readonly bounds: VolumeBounds;
|
|
40
|
+
readonly extinction: readonly [number, number, number];
|
|
41
|
+
readonly albedo: readonly [number, number, number];
|
|
42
|
+
readonly emission: readonly [number, number, number];
|
|
43
|
+
readonly anisotropy: number;
|
|
44
|
+
readonly maxDistance: number;
|
|
45
|
+
readonly ownerCount?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type VolumetricFogLightSelection =
|
|
49
|
+
| { readonly light: EntityHandle; readonly spotLight?: undefined }
|
|
50
|
+
| { readonly light: EntityHandle; readonly spotLight: EntityHandle };
|
|
51
|
+
|
|
52
|
+
export interface ValidatedVolumetricFog extends VolumetricFogAuthoring {
|
|
53
|
+
readonly ownerCount: 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const VolumetricFog = defineComponent('VolumetricFog', {
|
|
57
|
+
light: { type: 'entity' },
|
|
58
|
+
spotLight: { type: 'entity' },
|
|
59
|
+
density: { type: 'shared<TextureAsset>', simulationTransient: true },
|
|
60
|
+
boundsMin: { type: 'array<f32, 3>' },
|
|
61
|
+
boundsMax: { type: 'array<f32, 3>' },
|
|
62
|
+
extinction: { type: 'array<f32, 3>' },
|
|
63
|
+
albedo: { type: 'array<f32, 3>' },
|
|
64
|
+
emission: { type: 'array<f32, 3>' },
|
|
65
|
+
anisotropy: { type: 'f32', default: 0 },
|
|
66
|
+
maxDistance: { type: 'f32', default: 100 },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function finiteVector(value: readonly [number, number, number]): boolean {
|
|
70
|
+
return value.every(Number.isFinite);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validBounds(bounds: VolumeBounds): boolean {
|
|
74
|
+
if (!finiteVector(bounds.min) || !finiteVector(bounds.max)) return false;
|
|
75
|
+
return bounds.max.every((value, index) => value > (bounds.min[index] ?? value));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validRange(
|
|
79
|
+
value: readonly [number, number, number],
|
|
80
|
+
min: number,
|
|
81
|
+
max = Infinity,
|
|
82
|
+
): boolean {
|
|
83
|
+
return value.every((entry) => Number.isFinite(entry) && entry >= min && entry <= max);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function validateVolumetricFog(
|
|
87
|
+
input: VolumetricFogAuthoring,
|
|
88
|
+
): Result<ValidatedVolumetricFog, VolumeError> {
|
|
89
|
+
const ownerCount = input.ownerCount ?? 1;
|
|
90
|
+
if (ownerCount !== 1) return err(new VolumeOwnerConflictError(ownerCount));
|
|
91
|
+
if (input.density.shape.viewDimension !== '3d' || input.density.colorSpace === 'srgb') {
|
|
92
|
+
return err(
|
|
93
|
+
new VolumeDensityShapeMismatchError(input.density.guid, input.density.shape.viewDimension),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (!validBounds(input.bounds)) return err(new VolumeInvalidBoundsError(input.bounds));
|
|
97
|
+
if (
|
|
98
|
+
!validRange(input.extinction, 0) ||
|
|
99
|
+
!validRange(input.albedo, 0, 1) ||
|
|
100
|
+
!validRange(input.emission, 0) ||
|
|
101
|
+
!Number.isFinite(input.anisotropy) ||
|
|
102
|
+
input.anisotropy <= -1 ||
|
|
103
|
+
input.anisotropy >= 1 ||
|
|
104
|
+
!Number.isFinite(input.maxDistance) ||
|
|
105
|
+
input.maxDistance <= 0
|
|
106
|
+
) {
|
|
107
|
+
return err(new VolumeInvalidParametersError(input));
|
|
108
|
+
}
|
|
109
|
+
return ok({ ...input, ownerCount: 1 });
|
|
110
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
import { type VolumeError, VolumeOwnerConflictError } from '../errors/render';
|
|
3
|
+
import {
|
|
4
|
+
type ValidatedVolumetricFog,
|
|
5
|
+
type VolumetricFogAuthoring,
|
|
6
|
+
validateVolumetricFog,
|
|
7
|
+
} from './component';
|
|
8
|
+
|
|
9
|
+
export type VolumetricFogExtract =
|
|
10
|
+
| { readonly status: 'off' }
|
|
11
|
+
| { readonly status: 'available'; readonly fog: ValidatedVolumetricFog };
|
|
12
|
+
|
|
13
|
+
export function extractVolumetricFog(
|
|
14
|
+
inputs: readonly VolumetricFogAuthoring[],
|
|
15
|
+
): Result<VolumetricFogExtract, VolumeError> {
|
|
16
|
+
if (inputs.length === 0) return ok({ status: 'off' });
|
|
17
|
+
if (inputs.length !== 1) return err(new VolumeOwnerConflictError(inputs.length));
|
|
18
|
+
const validated = validateVolumetricFog(inputs[0] as VolumetricFogAuthoring);
|
|
19
|
+
if (!validated.ok) return validated;
|
|
20
|
+
return ok({ status: 'available', fog: validated.value });
|
|
21
|
+
}
|