@forgeax/engine-assets-runtime 0.1.7 → 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/__tests__/texture-pack-loader.unit.test.d.ts +2 -0
- package/dist/__tests__/texture-pack-loader.unit.test.d.ts.map +1 -0
- package/dist/decode-image-bytes.d.ts +2 -2
- package/dist/decode-image-bytes.d.ts.map +1 -1
- package/dist/errors/asset.d.ts +20 -0
- package/dist/errors/asset.d.ts.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +264 -198
- package/dist/index.mjs.map +1 -1
- package/dist/loaders/pack-artifact.d.ts +25 -1
- package/dist/loaders/pack-artifact.d.ts.map +1 -1
- package/dist/mipmap-generator.d.ts +3 -1
- package/dist/mipmap-generator.d.ts.map +1 -1
- package/dist/registry/instantiate.d.ts +2 -0
- package/dist/registry/instantiate.d.ts.map +1 -1
- package/dist/registry/validate-material.d.ts +26 -0
- package/dist/registry/validate-material.d.ts.map +1 -1
- package/package.json +14 -14
- package/src/__tests__/catalog-replica.unit.test.ts +36 -0
- package/src/__tests__/decode-image-bytes.browser.test.ts +5 -8
- package/src/__tests__/inline-pack-loaders.unit.test.ts +37 -0
- package/src/__tests__/instantiate-publication-fence.unit.test.ts +59 -1
- package/src/__tests__/material-ready.integration.test.ts +65 -0
- package/src/__tests__/pack-basis-load.integration.test.ts +66 -7
- package/src/__tests__/texture-pack-loader.unit.test.ts +145 -0
- package/src/__tests__/validate-material.unit.test.ts +45 -0
- package/src/decode-image-bytes.ts +6 -11
- package/src/errors/asset.ts +33 -0
- package/src/index.ts +7 -2
- package/src/loaders/pack-artifact.ts +218 -17
- package/src/mipmap-generator.ts +43 -1
- package/src/registry/instantiate.ts +14 -4
- package/src/registry/validate-material.ts +148 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { LoadContext } from '@forgeax/engine-types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { loadVerifiedTexturePack, type TexturePackLoadInput } from '../loaders/pack-artifact.js';
|
|
5
|
+
import {
|
|
6
|
+
createTexturePublicationState,
|
|
7
|
+
type TexturePublicationState,
|
|
8
|
+
} from '../registry/validate-material.js';
|
|
9
|
+
|
|
10
|
+
const GUID = '019ffa97-3000-7000-8000-000000000301';
|
|
11
|
+
const SOURCE_KEY = 'density/volume';
|
|
12
|
+
|
|
13
|
+
function digest(bytes: Uint8Array): string {
|
|
14
|
+
return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const context: LoadContext = {
|
|
18
|
+
fetchBinary: async () => ({ ok: false as const, error: new Error('not used') }),
|
|
19
|
+
resolveRef: async () => ({ ok: false as const, error: new Error('not used') }),
|
|
20
|
+
transcodeCaps: { bc: false, etc2: false, astc: false },
|
|
21
|
+
device: undefined,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function input(
|
|
25
|
+
bytes: Uint8Array,
|
|
26
|
+
overrides: Partial<TexturePackLoadInput> = {},
|
|
27
|
+
): TexturePackLoadInput {
|
|
28
|
+
const actualDigest = digest(bytes);
|
|
29
|
+
return {
|
|
30
|
+
sourceKey: SOURCE_KEY,
|
|
31
|
+
generation: 2,
|
|
32
|
+
expectedDigest: actualDigest,
|
|
33
|
+
pack: {
|
|
34
|
+
guid: GUID,
|
|
35
|
+
kind: 'texture',
|
|
36
|
+
payload: {
|
|
37
|
+
shape: { viewDimension: '3d', extent: { width: 2, height: 2, depth: 2 } },
|
|
38
|
+
format: 'r8unorm',
|
|
39
|
+
colorSpace: 'linear',
|
|
40
|
+
mips: { kind: 'none' },
|
|
41
|
+
},
|
|
42
|
+
refs: [],
|
|
43
|
+
artifacts: {
|
|
44
|
+
body: {
|
|
45
|
+
bytes,
|
|
46
|
+
descriptor: {
|
|
47
|
+
path: 'density.raw',
|
|
48
|
+
mediaType: 'application/x-forgeax-r8',
|
|
49
|
+
byteLength: 8,
|
|
50
|
+
integrity: { algorithm: 'sha256', digest: actualDigest },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('verified texture Pack loader', () => {
|
|
60
|
+
it('rejects bad byte length with GUID, generation, and loader stage', async () => {
|
|
61
|
+
const result = await loadVerifiedTexturePack(input(new Uint8Array(7)), context);
|
|
62
|
+
|
|
63
|
+
expect(result.ok).toBe(false);
|
|
64
|
+
if (!result.ok) {
|
|
65
|
+
expect(result.error.code).toBe('texture-pack-verification-failed');
|
|
66
|
+
expect(result.error.detail).toMatchObject({
|
|
67
|
+
guid: GUID,
|
|
68
|
+
sourceKey: SOURCE_KEY,
|
|
69
|
+
generation: 2,
|
|
70
|
+
stage: 'loader',
|
|
71
|
+
expectedBytes: 8,
|
|
72
|
+
actualBytes: 7,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects digest and canonical order mismatch before exposing an asset', async () => {
|
|
78
|
+
const digestMismatch = await loadVerifiedTexturePack(
|
|
79
|
+
input(new Uint8Array(8), { expectedDigest: 'sha256:not-the-artifact' }),
|
|
80
|
+
context,
|
|
81
|
+
);
|
|
82
|
+
expect(digestMismatch.ok).toBe(false);
|
|
83
|
+
if (!digestMismatch.ok) {
|
|
84
|
+
expect(digestMismatch.error.code).toBe('texture-pack-verification-failed');
|
|
85
|
+
expect(digestMismatch.error.detail).toMatchObject({
|
|
86
|
+
guid: GUID,
|
|
87
|
+
generation: 2,
|
|
88
|
+
stage: 'loader',
|
|
89
|
+
cause: 'digest-mismatch',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const orderMismatch = await loadVerifiedTexturePack(
|
|
94
|
+
input(new Uint8Array(8), {
|
|
95
|
+
pack: {
|
|
96
|
+
...input(new Uint8Array(8)).pack,
|
|
97
|
+
payload: {
|
|
98
|
+
...input(new Uint8Array(8)).pack.payload,
|
|
99
|
+
packingOrder: 'row-major,mip-major,image-major',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
context,
|
|
104
|
+
);
|
|
105
|
+
expect(orderMismatch.ok).toBe(false);
|
|
106
|
+
if (!orderMismatch.ok) {
|
|
107
|
+
expect(orderMismatch.error.detail).toMatchObject({
|
|
108
|
+
guid: GUID,
|
|
109
|
+
stage: 'loader',
|
|
110
|
+
cause: 'packing-order-mismatch',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('advances accepted generation only after a verified candidate succeeds', async () => {
|
|
116
|
+
const state: TexturePublicationState = createTexturePublicationState({
|
|
117
|
+
guid: GUID,
|
|
118
|
+
sourceKey: SOURCE_KEY,
|
|
119
|
+
generation: 1,
|
|
120
|
+
digest: 'sha256:accepted',
|
|
121
|
+
});
|
|
122
|
+
const failed = await loadVerifiedTexturePack(input(new Uint8Array(7)), context);
|
|
123
|
+
expect(failed.ok).toBe(false);
|
|
124
|
+
const afterFailure = state.reject({
|
|
125
|
+
generation: 2,
|
|
126
|
+
reason: 'byte-length',
|
|
127
|
+
});
|
|
128
|
+
expect(afterFailure).toEqual({
|
|
129
|
+
guid: GUID,
|
|
130
|
+
sourceKey: SOURCE_KEY,
|
|
131
|
+
acceptedGeneration: 1,
|
|
132
|
+
acceptedDigest: 'sha256:accepted',
|
|
133
|
+
candidateGeneration: 2,
|
|
134
|
+
candidateFailure: 'byte-length',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const accepted = state.accept({ generation: 2, digest: 'sha256:repaired' });
|
|
138
|
+
expect(accepted).toEqual({
|
|
139
|
+
guid: GUID,
|
|
140
|
+
sourceKey: SOURCE_KEY,
|
|
141
|
+
acceptedGeneration: 2,
|
|
142
|
+
acceptedDigest: 'sha256:repaired',
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
materialShaderTextureFieldNames,
|
|
19
19
|
validateMaterialCookIdentity,
|
|
20
20
|
validateMaterialPasses,
|
|
21
|
+
validateMaterialTransmissionContract,
|
|
21
22
|
validateParamType,
|
|
22
23
|
validateSpriteSlices,
|
|
23
24
|
} from '../registry/validate-material';
|
|
@@ -180,6 +181,50 @@ describe('validateMaterialPasses', () => {
|
|
|
180
181
|
});
|
|
181
182
|
});
|
|
182
183
|
|
|
184
|
+
describe('validateMaterialTransmissionContract', () => {
|
|
185
|
+
it.each([
|
|
186
|
+
['transmission range', { transmission: 1.1 }],
|
|
187
|
+
['ior range', { ior: 0 }],
|
|
188
|
+
['thickness range', { thickness: -0.1 }],
|
|
189
|
+
['attenuation distance', { attenuationDistance: Number.POSITIVE_INFINITY }],
|
|
190
|
+
])('returns a structured error for %s', (_name, value) => {
|
|
191
|
+
const error = validateMaterialTransmissionContract(mat({ values: value }));
|
|
192
|
+
expect(error?.code).toBe('material-transmission-contract-invalid');
|
|
193
|
+
expect(error?.detail).toMatchObject({ reason: expect.any(String) });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('rejects BLEND and depth writes when transmission is active', () => {
|
|
197
|
+
expect(
|
|
198
|
+
validateMaterialTransmissionContract(
|
|
199
|
+
mat({
|
|
200
|
+
values: { transmission: 0.5 },
|
|
201
|
+
passes: [
|
|
202
|
+
{
|
|
203
|
+
name: 'forward',
|
|
204
|
+
program: { module: 'forgeax_material::standard' },
|
|
205
|
+
renderState: { blend: {} },
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
}),
|
|
209
|
+
)?.detail,
|
|
210
|
+
).toMatchObject({ reason: 'blend' });
|
|
211
|
+
expect(
|
|
212
|
+
validateMaterialTransmissionContract(
|
|
213
|
+
mat({
|
|
214
|
+
values: { transmission: 0.5 },
|
|
215
|
+
passes: [
|
|
216
|
+
{
|
|
217
|
+
name: 'forward',
|
|
218
|
+
program: { module: 'forgeax_material::standard' },
|
|
219
|
+
renderState: { depthWriteEnabled: true },
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
}),
|
|
223
|
+
)?.detail,
|
|
224
|
+
).toMatchObject({ reason: 'depth-write' });
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
183
228
|
describe('validateParamType', () => {
|
|
184
229
|
const reg = makeRegistry({});
|
|
185
230
|
it('numeric scalars', () => {
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// 3. Map `DecodedImage` -> `TextureAsset` POD; `format` derives from
|
|
11
11
|
// `opts.colorSpace` (srgb -> 'rgba8unorm-srgb' / linear ->
|
|
12
12
|
// 'rgba8unorm', mirrors `image-importer.ts` colorSpaceToFormat); when
|
|
13
|
-
// `opts.mipmap !== false`,
|
|
14
|
-
//
|
|
13
|
+
// `opts.mipmap !== false`, the mip policy derives from the decoded shape
|
|
14
|
+
// (charter Derive-Don't-Duplicate).
|
|
15
15
|
//
|
|
16
16
|
// decode-image-bytes.ts is the SINGLE assets-runtime file allowed to
|
|
17
17
|
// statically import @forgeax/engine-image (charter D-2; the
|
|
@@ -24,7 +24,6 @@ import { decodeImageInBrowser } from '@forgeax/engine-image';
|
|
|
24
24
|
import type { ImageError, Result, TextureAsset } from '@forgeax/engine-types';
|
|
25
25
|
import { err, ok } from '@forgeax/engine-types';
|
|
26
26
|
import { makeImageError } from './image-error';
|
|
27
|
-
import { numMipLevels } from './mipmap-generator';
|
|
28
27
|
|
|
29
28
|
/** v1 mime whitelist -- PNG / JPEG only (charter P1 progressive disclosure). */
|
|
30
29
|
const SUPPORTED_MIMES = ['image/png', 'image/jpeg'] as const;
|
|
@@ -60,8 +59,8 @@ function isSupportedMime(mime: string): mime is SupportedMime {
|
|
|
60
59
|
* @param opts - Optional decode overrides:
|
|
61
60
|
* - `colorSpace`: `'srgb'` (default) or `'linear'`. Derives POD `format`:
|
|
62
61
|
* `srgb -> 'rgba8unorm-srgb'`, `linear -> 'rgba8unorm'`.
|
|
63
|
-
* - `mipmap`: `true` (default) or `false`. When true,
|
|
64
|
-
*
|
|
62
|
+
* - `mipmap`: `true` (default) or `false`. When true, the producer requests
|
|
63
|
+
* generated mips; when false, it keeps one authored level.
|
|
65
64
|
* @returns `ok(TextureAsset)` on success; `err(ImageError)` on failure.
|
|
66
65
|
*
|
|
67
66
|
* @example
|
|
@@ -110,17 +109,13 @@ export async function decodeImageBytes(
|
|
|
110
109
|
|
|
111
110
|
const dec = decoded.value;
|
|
112
111
|
const format: GPUTextureFormat = colorSpace === 'srgb' ? 'rgba8unorm-srgb' : 'rgba8unorm';
|
|
113
|
-
const mipLevelCount = mipmap ? numMipLevels({ width: dec.width, height: dec.height }) : 1;
|
|
114
|
-
|
|
115
112
|
const pod: TextureAsset = {
|
|
116
113
|
kind: 'texture',
|
|
117
|
-
width: dec.width,
|
|
118
|
-
height: dec.height,
|
|
114
|
+
shape: { viewDimension: '2d', extent: { width: dec.width, height: dec.height } },
|
|
119
115
|
format,
|
|
120
116
|
data: dec.bytes,
|
|
121
117
|
colorSpace,
|
|
122
|
-
mipmap,
|
|
123
|
-
mipLevelCount,
|
|
118
|
+
mips: mipmap ? { kind: 'generate' } : { kind: 'none' },
|
|
124
119
|
};
|
|
125
120
|
return ok(pod);
|
|
126
121
|
}
|
package/src/errors/asset.ts
CHANGED
|
@@ -18,6 +18,39 @@ import {
|
|
|
18
18
|
type AssetMeshBinContractViolationReason,
|
|
19
19
|
} from '@forgeax/engine-types';
|
|
20
20
|
|
|
21
|
+
export type TexturePackVerificationCause =
|
|
22
|
+
| 'byte-length'
|
|
23
|
+
| 'digest-mismatch'
|
|
24
|
+
| 'packing-order-mismatch'
|
|
25
|
+
| 'shape-mismatch';
|
|
26
|
+
|
|
27
|
+
export interface TexturePackVerificationDetail {
|
|
28
|
+
readonly guid: string;
|
|
29
|
+
readonly sourceKey: string;
|
|
30
|
+
readonly generation: number;
|
|
31
|
+
readonly stage: 'producer' | 'loader';
|
|
32
|
+
readonly cause: TexturePackVerificationCause;
|
|
33
|
+
readonly expectedBytes?: number;
|
|
34
|
+
readonly actualBytes?: number;
|
|
35
|
+
readonly expectedDigest?: string;
|
|
36
|
+
readonly actualDigest?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Structured failure for the producer/loader texture contract. */
|
|
40
|
+
export class TexturePackVerificationError extends Error {
|
|
41
|
+
readonly code = 'texture-pack-verification-failed' as const;
|
|
42
|
+
readonly expected: string;
|
|
43
|
+
readonly hint = 'repair the producer output and retry the same texture GUID generation';
|
|
44
|
+
readonly detail: TexturePackVerificationDetail;
|
|
45
|
+
|
|
46
|
+
constructor(detail: TexturePackVerificationDetail, expected: string) {
|
|
47
|
+
super(`[TexturePackVerificationError] ${detail.cause}: ${expected}`);
|
|
48
|
+
this.name = 'TexturePackVerificationError';
|
|
49
|
+
this.expected = expected;
|
|
50
|
+
this.detail = detail;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
21
54
|
export class MeshBinAssetError extends AssetError {
|
|
22
55
|
readonly subject = 'mesh-bin' as const;
|
|
23
56
|
readonly sourceKey: string;
|
package/src/index.ts
CHANGED
|
@@ -143,6 +143,7 @@ export {
|
|
|
143
143
|
// ─── Mipmap generation helpers ──────────────────────────────────────────────
|
|
144
144
|
export {
|
|
145
145
|
blitMipmapsSync,
|
|
146
|
+
encodeMipmapLevel,
|
|
146
147
|
getOrCreateMipmapPipeline,
|
|
147
148
|
type MipmapBlitDevice,
|
|
148
149
|
type MipmapShaderModuleFactory,
|
|
@@ -157,9 +158,13 @@ export {
|
|
|
157
158
|
type RuntimeEvidenceSource,
|
|
158
159
|
} from './registry/asset-evidence';
|
|
159
160
|
export { CatalogReplica, type CatalogReplicaSnapshot } from './registry/catalog-state';
|
|
160
|
-
export type { PostSpawnHook, SkinJointResolver } from './registry/instantiate';
|
|
161
161
|
// ─── Scene instantiate collaboration contract types (D-1 injected hook) ─────
|
|
162
|
-
export {
|
|
162
|
+
export {
|
|
163
|
+
buildSceneChildContext,
|
|
164
|
+
type PostSpawnHook,
|
|
165
|
+
type SkinJointResolver,
|
|
166
|
+
scenePublicationFenceFromRegistry,
|
|
167
|
+
} from './registry/instantiate';
|
|
163
168
|
export { loadMaterialReadyByGuid } from './registry/load-by-guid';
|
|
164
169
|
export {
|
|
165
170
|
compareScenePublicationFences,
|
|
@@ -12,12 +12,33 @@ import type {
|
|
|
12
12
|
TextureAsset,
|
|
13
13
|
TilesetAsset,
|
|
14
14
|
} from '@forgeax/engine-types';
|
|
15
|
-
import { AssetError } from '@forgeax/engine-types';
|
|
15
|
+
import { AssetError, deriveTextureLayout } from '@forgeax/engine-types';
|
|
16
|
+
import {
|
|
17
|
+
type TexturePackVerificationDetail,
|
|
18
|
+
TexturePackVerificationError,
|
|
19
|
+
} from '../errors/asset.js';
|
|
16
20
|
import type { PackLoaderInput } from '../loader-registry';
|
|
17
|
-
import { numMipLevels } from '../mipmap-generator';
|
|
18
21
|
|
|
19
22
|
type PackArtifact = PackLoaderInput['artifacts'][string];
|
|
20
23
|
|
|
24
|
+
export interface TexturePackLoadInput {
|
|
25
|
+
readonly pack: PackLoaderInput;
|
|
26
|
+
readonly sourceKey: string;
|
|
27
|
+
readonly generation: number;
|
|
28
|
+
readonly expectedDigest: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type VerifiedTexturePack = {
|
|
32
|
+
readonly asset: TextureAsset;
|
|
33
|
+
readonly sourceKey: string;
|
|
34
|
+
readonly generation: number;
|
|
35
|
+
readonly digest: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type VerifiedTexturePackResult =
|
|
39
|
+
| { readonly ok: true; readonly value: VerifiedTexturePack }
|
|
40
|
+
| { readonly ok: false; readonly error: TexturePackVerificationError | AssetError };
|
|
41
|
+
|
|
21
42
|
type CodecFailureProjection = {
|
|
22
43
|
readonly code: string;
|
|
23
44
|
readonly expected: string;
|
|
@@ -38,6 +59,36 @@ function payloadColorSpace(payload: Record<string, unknown>): 'srgb' | 'linear'
|
|
|
38
59
|
return payload.colorSpace === 'linear' ? 'linear' : 'srgb';
|
|
39
60
|
}
|
|
40
61
|
|
|
62
|
+
function payloadTextureShape(payload: Record<string, unknown>): TextureAsset['shape'] | undefined {
|
|
63
|
+
const shape = payload.shape;
|
|
64
|
+
if (!record(shape) || typeof shape.viewDimension !== 'string' || !record(shape.extent)) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
const { width, height, layers, depth } = shape.extent;
|
|
68
|
+
if (!positiveInteger(width) || !positiveInteger(height)) return undefined;
|
|
69
|
+
if (shape.viewDimension === '2d' && layers === undefined && depth === undefined) {
|
|
70
|
+
return { viewDimension: '2d', extent: { width, height } };
|
|
71
|
+
}
|
|
72
|
+
if (shape.viewDimension === '2d-array' && positiveInteger(layers) && depth === undefined) {
|
|
73
|
+
return { viewDimension: '2d-array', extent: { width, height, layers } };
|
|
74
|
+
}
|
|
75
|
+
if (shape.viewDimension === '3d' && positiveInteger(depth) && layers === undefined) {
|
|
76
|
+
return { viewDimension: '3d', extent: { width, height, depth } };
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function payloadTextureMips(payload: Record<string, unknown>): TextureAsset['mips'] | undefined {
|
|
82
|
+
const mips = payload.mips;
|
|
83
|
+
if (!record(mips) || typeof mips.kind !== 'string') return undefined;
|
|
84
|
+
if (mips.kind === 'none') return { kind: 'none' };
|
|
85
|
+
if (mips.kind === 'generate') return { kind: 'generate' };
|
|
86
|
+
if (mips.kind === 'packed' && positiveInteger(mips.levelCount)) {
|
|
87
|
+
return { kind: 'packed', levelCount: mips.levelCount };
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
41
92
|
function invalidPackAsset<T>(input: PackLoaderInput, expected: string): LoaderAsyncResult<T> {
|
|
42
93
|
return {
|
|
43
94
|
ok: false,
|
|
@@ -62,6 +113,32 @@ function positiveInteger(value: unknown): value is number {
|
|
|
62
113
|
return finiteNumber(value) && Number.isInteger(value) && value > 0;
|
|
63
114
|
}
|
|
64
115
|
|
|
116
|
+
async function textureDigest(bytes: Uint8Array): Promise<string> {
|
|
117
|
+
const subtle = globalThis.crypto?.subtle;
|
|
118
|
+
if (subtle === undefined) throw new Error('Web Crypto API is required for texture verification');
|
|
119
|
+
const digest = await subtle.digest('SHA-256', bytes.slice().buffer as ArrayBuffer);
|
|
120
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function textureVerificationError(
|
|
124
|
+
input: TexturePackLoadInput,
|
|
125
|
+
cause: TexturePackVerificationDetail['cause'],
|
|
126
|
+
expected: string,
|
|
127
|
+
fields: Partial<TexturePackVerificationDetail> = {},
|
|
128
|
+
): TexturePackVerificationError {
|
|
129
|
+
return new TexturePackVerificationError(
|
|
130
|
+
{
|
|
131
|
+
guid: input.pack.guid,
|
|
132
|
+
sourceKey: input.sourceKey,
|
|
133
|
+
generation: input.generation,
|
|
134
|
+
stage: 'loader',
|
|
135
|
+
cause,
|
|
136
|
+
...fields,
|
|
137
|
+
},
|
|
138
|
+
expected,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
65
142
|
function validRenderPipelineConfig(value: unknown): value is Record<string, unknown> {
|
|
66
143
|
if (!record(value)) return false;
|
|
67
144
|
const passCount = value.passCount;
|
|
@@ -295,8 +372,6 @@ async function loadTexturePack(
|
|
|
295
372
|
if (artifact === undefined)
|
|
296
373
|
return invalidPackAsset<TextureAsset>(input, 'texture asset-local image artifact');
|
|
297
374
|
const payload = input.payload;
|
|
298
|
-
const width = payloadNumber(payload, 'width', 0);
|
|
299
|
-
const height = payloadNumber(payload, 'height', 0);
|
|
300
375
|
const colorSpace = payloadColorSpace(payload);
|
|
301
376
|
const codec = artifact.descriptor.assetCodec;
|
|
302
377
|
const profile = codecProfile(codec);
|
|
@@ -346,13 +421,14 @@ async function loadTexturePack(
|
|
|
346
421
|
ok: true,
|
|
347
422
|
value: {
|
|
348
423
|
kind: 'texture',
|
|
349
|
-
|
|
350
|
-
|
|
424
|
+
shape: {
|
|
425
|
+
viewDimension: '2d',
|
|
426
|
+
extent: { width: transcoded.value.width, height: transcoded.value.height },
|
|
427
|
+
},
|
|
351
428
|
format: target,
|
|
352
429
|
data,
|
|
353
430
|
colorSpace,
|
|
354
|
-
|
|
355
|
-
mipLevelCount: Math.max(1, transcoded.value.mips.length),
|
|
431
|
+
mips: { kind: 'packed', levelCount: Math.max(1, transcoded.value.mips.length) },
|
|
356
432
|
},
|
|
357
433
|
};
|
|
358
434
|
} catch (error) {
|
|
@@ -433,13 +509,14 @@ async function loadTexturePack(
|
|
|
433
509
|
ok: true,
|
|
434
510
|
value: {
|
|
435
511
|
kind: 'texture',
|
|
436
|
-
|
|
437
|
-
|
|
512
|
+
shape: {
|
|
513
|
+
viewDimension: '2d',
|
|
514
|
+
extent: { width: transcoded.value.width, height: transcoded.value.height },
|
|
515
|
+
},
|
|
438
516
|
format: target,
|
|
439
517
|
data,
|
|
440
518
|
colorSpace,
|
|
441
|
-
|
|
442
|
-
mipLevelCount: Math.max(1, transcoded.value.mips.length),
|
|
519
|
+
mips: { kind: 'packed', levelCount: Math.max(1, transcoded.value.mips.length) },
|
|
443
520
|
},
|
|
444
521
|
};
|
|
445
522
|
} catch (error) {
|
|
@@ -465,19 +542,143 @@ async function loadTexturePack(
|
|
|
465
542
|
}
|
|
466
543
|
}
|
|
467
544
|
|
|
468
|
-
const
|
|
545
|
+
const shape = payloadTextureShape(payload);
|
|
546
|
+
const mips = payloadTextureMips(payload);
|
|
547
|
+
if (shape === undefined || mips === undefined) {
|
|
548
|
+
return invalidPackAsset<TextureAsset>(
|
|
549
|
+
input,
|
|
550
|
+
'texture payload with a valid shape and mip policy',
|
|
551
|
+
);
|
|
552
|
+
}
|
|
469
553
|
return {
|
|
470
554
|
ok: true,
|
|
471
555
|
value: {
|
|
472
556
|
kind: 'texture',
|
|
473
|
-
|
|
474
|
-
height,
|
|
557
|
+
shape,
|
|
475
558
|
format: (payload.format ??
|
|
476
559
|
(colorSpace === 'srgb' ? 'rgba8unorm-srgb' : 'rgba8unorm')) as GPUTextureFormat,
|
|
477
560
|
data: artifact.bytes,
|
|
478
561
|
colorSpace,
|
|
479
|
-
|
|
480
|
-
|
|
562
|
+
mips,
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Verify producer facts and then expose one accepted texture projection. */
|
|
568
|
+
export async function loadVerifiedTexturePack(
|
|
569
|
+
input: TexturePackLoadInput,
|
|
570
|
+
ctx: LoadContext,
|
|
571
|
+
): Promise<VerifiedTexturePackResult> {
|
|
572
|
+
const artifact = firstArtifact(input.pack);
|
|
573
|
+
if (artifact === undefined) {
|
|
574
|
+
return {
|
|
575
|
+
ok: false,
|
|
576
|
+
error: textureVerificationError(input, 'byte-length', 'one asset-local body artifact', {
|
|
577
|
+
expectedBytes: 0,
|
|
578
|
+
actualBytes: 0,
|
|
579
|
+
}),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
const actualBytes = artifact.bytes.byteLength;
|
|
583
|
+
const declaredBytes = artifact.descriptor.byteLength;
|
|
584
|
+
if (declaredBytes !== undefined && declaredBytes !== actualBytes) {
|
|
585
|
+
return {
|
|
586
|
+
ok: false,
|
|
587
|
+
error: textureVerificationError(
|
|
588
|
+
input,
|
|
589
|
+
'byte-length',
|
|
590
|
+
`artifact byte length ${declaredBytes}`,
|
|
591
|
+
{ expectedBytes: declaredBytes, actualBytes },
|
|
592
|
+
),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
const payloadOrder = input.pack.payload.packingOrder;
|
|
596
|
+
if (payloadOrder !== undefined && payloadOrder !== 'mip-major,image-major,row-major') {
|
|
597
|
+
return {
|
|
598
|
+
ok: false,
|
|
599
|
+
error: textureVerificationError(
|
|
600
|
+
input,
|
|
601
|
+
'packing-order-mismatch',
|
|
602
|
+
'mip-major,image-major,row-major packing order',
|
|
603
|
+
),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
const shape = payloadTextureShape(input.pack.payload);
|
|
607
|
+
const mips = payloadTextureMips(input.pack.payload);
|
|
608
|
+
const format = (input.pack.payload.format ??
|
|
609
|
+
(payloadColorSpace(input.pack.payload) === 'srgb'
|
|
610
|
+
? 'rgba8unorm-srgb'
|
|
611
|
+
: 'rgba8unorm')) as GPUTextureFormat;
|
|
612
|
+
if (shape === undefined || mips === undefined) {
|
|
613
|
+
return {
|
|
614
|
+
ok: false,
|
|
615
|
+
error: textureVerificationError(input, 'shape-mismatch', 'valid TextureAsset shape and mips'),
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
const layout = deriveTextureLayout({
|
|
619
|
+
shape,
|
|
620
|
+
format,
|
|
621
|
+
mips,
|
|
622
|
+
actualByteLength: actualBytes,
|
|
623
|
+
order: 'mip-major,image-major,row-major',
|
|
624
|
+
});
|
|
625
|
+
if (!layout.ok) {
|
|
626
|
+
const expectedBytes =
|
|
627
|
+
layout.error.code === 'texture-packing-invalid'
|
|
628
|
+
? layout.error.detail.expectedBytes
|
|
629
|
+
: actualBytes;
|
|
630
|
+
const actualLayoutBytes =
|
|
631
|
+
layout.error.code === 'texture-packing-invalid'
|
|
632
|
+
? layout.error.detail.actualBytes
|
|
633
|
+
: actualBytes;
|
|
634
|
+
return {
|
|
635
|
+
ok: false,
|
|
636
|
+
error: textureVerificationError(
|
|
637
|
+
input,
|
|
638
|
+
'byte-length',
|
|
639
|
+
`canonical texture byte length ${expectedBytes}`,
|
|
640
|
+
{
|
|
641
|
+
expectedBytes,
|
|
642
|
+
actualBytes: actualLayoutBytes,
|
|
643
|
+
},
|
|
644
|
+
),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
const actualDigest = await textureDigest(artifact.bytes);
|
|
648
|
+
const descriptorDigest = artifact.descriptor.integrity?.digest;
|
|
649
|
+
if (descriptorDigest !== input.expectedDigest || actualDigest !== input.expectedDigest) {
|
|
650
|
+
return {
|
|
651
|
+
ok: false,
|
|
652
|
+
error: textureVerificationError(
|
|
653
|
+
input,
|
|
654
|
+
'digest-mismatch',
|
|
655
|
+
`artifact digest ${input.expectedDigest}`,
|
|
656
|
+
{
|
|
657
|
+
expectedDigest: input.expectedDigest,
|
|
658
|
+
actualDigest,
|
|
659
|
+
},
|
|
660
|
+
),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
const loaded = await loadTexturePack(input.pack, ctx);
|
|
664
|
+
if (!loaded.ok) {
|
|
665
|
+
if (loaded.error instanceof AssetError) return { ok: false, error: loaded.error };
|
|
666
|
+
return {
|
|
667
|
+
ok: false,
|
|
668
|
+
error: textureVerificationError(
|
|
669
|
+
input,
|
|
670
|
+
'shape-mismatch',
|
|
671
|
+
'runtime texture loader to return a verified TextureAsset',
|
|
672
|
+
),
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
ok: true,
|
|
677
|
+
value: {
|
|
678
|
+
asset: loaded.value,
|
|
679
|
+
sourceKey: input.sourceKey,
|
|
680
|
+
generation: input.generation,
|
|
681
|
+
digest: actualDigest,
|
|
481
682
|
},
|
|
482
683
|
};
|
|
483
684
|
}
|
package/src/mipmap-generator.ts
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
// - `numMipLevels` is pure (no device argument); the WeakMap lookup only
|
|
46
46
|
// happens at pipeline-create time.
|
|
47
47
|
|
|
48
|
-
import { err, ok, type Result, RhiError } from '@forgeax/engine-rhi';
|
|
48
|
+
import { err, ok, type Result, RhiError, type RhiRenderPassEncoder } from '@forgeax/engine-rhi';
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Subset of `RhiDevice` consumed by the mipmap utility (charter F2: only
|
|
@@ -448,3 +448,45 @@ export function blitMipmapsSync(
|
|
|
448
448
|
|
|
449
449
|
return recordMipmapBlit<RhiError>(device, texture, levels, pipeline, cache);
|
|
450
450
|
}
|
|
451
|
+
|
|
452
|
+
/** Encode one prewarmed mip level into a caller-owned graph render pass. */
|
|
453
|
+
export function encodeMipmapLevel(
|
|
454
|
+
device: MipmapBlitDevice,
|
|
455
|
+
pass: RhiRenderPassEncoder,
|
|
456
|
+
sourceView: unknown,
|
|
457
|
+
format: GPUTextureFormat,
|
|
458
|
+
): Result<void, RhiError> {
|
|
459
|
+
const cache = deviceCache.get(device);
|
|
460
|
+
if (cache === undefined) {
|
|
461
|
+
return err(
|
|
462
|
+
new RhiError({
|
|
463
|
+
code: 'rhi-not-available',
|
|
464
|
+
expected: 'mipmap pipeline cache prewarmed for the graph device before encoding',
|
|
465
|
+
hint: 'call prewarmMipmapPipeline(device, formats) at renderer.ready',
|
|
466
|
+
}),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
const pipeline = cache.pipelines.get(format);
|
|
470
|
+
if (pipeline === undefined) {
|
|
471
|
+
return err(
|
|
472
|
+
new RhiError({
|
|
473
|
+
code: 'rhi-not-available',
|
|
474
|
+
expected: `mipmap pipeline for format ${format} prewarmed`,
|
|
475
|
+
hint: `format ${format} was not prewarmed; add it to the renderer prewarm list`,
|
|
476
|
+
}),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
const bindGroup = device.createBindGroup({
|
|
480
|
+
label: 'mipmap-graph-bind-group',
|
|
481
|
+
layout: cache.layout,
|
|
482
|
+
entries: [
|
|
483
|
+
{ binding: 0, resource: { kind: 'sampler', value: cache.sampler } },
|
|
484
|
+
{ binding: 1, resource: { kind: 'textureView', value: sourceView } },
|
|
485
|
+
],
|
|
486
|
+
});
|
|
487
|
+
if (!bindGroup.ok) return bindGroup;
|
|
488
|
+
pass.setPipeline(pipeline);
|
|
489
|
+
pass.setBindGroup(0, bindGroup.value);
|
|
490
|
+
pass.draw(3, 1, 0, 0);
|
|
491
|
+
return ok(undefined);
|
|
492
|
+
}
|