import type { MaterialAsset, MaterialParameter, MaterialPass, MaterialProgramAbi, MaterialProgramAddress, MaterialSurfaceDeclaration, MaterialTextureReference, MaterialTextureValue, MaterialValue, Result, } from '@forgeax/engine-types'; import { deriveStandardLayerPlan, err, isMaterialProgramAbi, isMaterialSurfaceDeclaration, isMaterialTextureParameterType, MATERIAL_TEXTURE_SLOTS, ok, } from '@forgeax/engine-types'; import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex } from '@noble/hashes/utils.js'; import { AssetGuid } from '../guid.js'; export interface MaterialCookRefs { readonly parent: readonly string[]; readonly textures: readonly string[]; readonly samplers: readonly string[]; readonly modules: readonly string[]; } export interface MaterialCookArtifact { readonly mediaType: string; readonly path: string; readonly digest: string; readonly bytes: Uint8Array; } /** Domain-owned inputs used to compile and select a material program. */ export interface MaterialCookRasterContext { readonly backend: 'webgpu' | 'webgl2' | 'wgpu-native'; readonly capability: 'storage-buffer' | 'storage-buffer-atmosphere' | 'uniform-fallback'; readonly pipeline: 'forward' | 'deferred'; /** Absent is the ordinary material ABI; enabled by the receiving renderer. */ readonly visibleSurface?: true; readonly geometry: 'mesh' | 'skinned' | 'sprite' | 'sprite-instances' | 'terrain'; readonly pass: 'forward' | 'shadow' | 'depth'; readonly profile: 'forgeax-material-wgsl-v1'; readonly toolchain: 'naga-oil'; readonly instrumentation: 'none' | 'validation'; } /** The same authored Surface evaluated at a ray hit or Card capture, without a submission address ABI. */ export interface MaterialCookRayContext { readonly backend: 'webgpu' | 'wgpu-native'; readonly capability: 'storage-buffer'; readonly pipeline: 'ray'; readonly geometry: 'mesh'; readonly pass: 'ray-hit' | 'card-capture'; readonly profile: 'forgeax-material-ray-v1'; readonly toolchain: 'naga-oil'; readonly instrumentation: 'none'; } export type MaterialCookProgramContext = MaterialCookRasterContext | MaterialCookRayContext; /** Programs are derived artifacts, never independently authored assets. */ export interface MaterialCookProgram { readonly specializationKey: string; readonly artifact: MaterialCookArtifact; readonly selections: readonly { readonly pass: string; readonly context: MaterialCookProgramContext; /** Renderer submission address selected by this publication. */ readonly address?: MaterialProgramAddress; /** Actual entry point paired with the selected address. */ readonly entry?: string; /** Producer-reflected ABI facts for the selected artifact/entry. */ readonly abi?: MaterialProgramAbi; }[]; } export interface MaterialCookWasmProvenance { readonly sourceContentKey: string; readonly artifactSha256: string; readonly glueSha256: string; } export interface MaterialCookIdentity { readonly materialContractDigest: string; readonly sourceRevision: string; readonly sourceClosureDigest: string; readonly layoutIdentity: string; readonly programIdentity: string; readonly pipelineIdentity: string; readonly materialPublicationIdentity: string; readonly cookIdentity: string; readonly compilerFingerprint: string; readonly wasm: MaterialCookWasmProvenance; readonly artifactDigest: string; readonly valueGeneration: number; readonly dependencyGeneration: number; readonly cookGeneration: number; } export type MaterialCookIdentityInput = Omit; export interface MaterialCookReceipt { readonly schemaVersion: 'material-cook/4'; readonly sourceClosure: readonly string[]; readonly profile: string; readonly compilerVersion: string; readonly identity: MaterialCookIdentity; readonly derivedInterface: { readonly layoutIdentity: string; /** Build-time Standard physical lowering identity, when the root is Standard. */ readonly layerPlanIdentity?: string; }; } export interface MaterialParameterContract { readonly parameters: readonly MaterialParameter[]; readonly values: Readonly>; } export interface CookedMaterialRecord { readonly schemaVersion: 'material-cook/4'; readonly guid: string; readonly authored?: MaterialAsset; readonly materialGuid?: string; readonly publicationGeneration?: number; readonly specializationKey?: string; readonly programs: readonly MaterialCookProgram[]; /** Digest of the complete program/Pass/context manifest. */ readonly artifactDigest?: string; readonly sourceClosure?: readonly string[]; readonly parameterContract?: MaterialParameterContract; readonly resolved: { readonly passes: readonly MaterialPass[]; readonly parameters: readonly MaterialParameter[]; readonly values: Readonly>; readonly surface?: MaterialSurfaceDeclaration; }; readonly refs: MaterialCookRefs; readonly receipt: MaterialCookReceipt; } export interface MaterialCookRecordError { readonly code: 'material-cook-record-invalid'; readonly expected: string; readonly hint: string; readonly detail: { readonly field: string; readonly actual?: unknown; readonly action: string; }; } export interface MaterialCookIdentityExpectation { readonly layoutIdentity?: string; readonly artifactDigest?: string; readonly inputDigest?: string; } const STANDARD_ROOT_MODULES = new Set([ 'forgeax::default-standard-pbr', 'forgeax::pbr-skin', 'forgeax_material::standard', 'forgeax_material::terrain', 'forgeax_material::pbr-skin', ]); /** Shared ownership predicate for the built-in Standard material root. */ export function isStandardRootModule(module: string): boolean { return STANDARD_ROOT_MODULES.has(module); } export function isStandardMaterialRecord(record: Pick): boolean { return record.resolved.passes.some((pass) => isStandardRootModule(pass.program.module)); } /** * Recompute the Standard layer identity from the admitted resolved root. * Material admission and runtime loading share this owner so a stale receipt * cannot select a different physical layer plan after cooking. */ export function materialLayerPlanIdentity( record: Pick, ): string | undefined { if (!isStandardMaterialRecord(record)) { return undefined; } return deriveStandardLayerPlan(record.resolved.parameters, record.resolved.passes).identity; } function unique(values: readonly string[]): readonly string[] { return [...new Set(values)].sort(); } function guidText(value: string | Uint8Array): string { return typeof value === 'string' ? value : AssetGuid.format(value as AssetGuid); } function cookGuidText(value: MaterialTextureReference): string | undefined { return typeof value === 'number' ? undefined : guidText(value); } function textureValues( values: Readonly> | undefined, textureFields: ReadonlySet, ): readonly MaterialTextureValue[] { return Object.entries(values ?? {}).flatMap(([name, value]) => { if (value === null) return []; if (typeof value === 'string') { return textureFields.has(name) ? [{ texture: value }] : []; } return value !== null && typeof value === 'object' && !Array.isArray(value) && 'texture' in value ? [value as MaterialTextureValue] : []; }); } export function collectMaterialCookRefs(material: Partial): MaterialCookRefs { const textureFields = material.parameters === undefined ? new Set(MATERIAL_TEXTURE_SLOTS) : new Set( material.parameters .filter((parameter) => isMaterialTextureParameterType(parameter.type)) .map((parameter) => parameter.name), ); const textures = textureValues(material.values, textureFields); return { parent: material.parent ? [guidText(material.parent)] : [], textures: unique( textures.flatMap((value) => { const guid = cookGuidText(value.texture); return guid === undefined ? [] : [guid]; }), ), samplers: unique( textures.flatMap((value) => { if (value.sampler === undefined) return []; const guid = cookGuidText(value.sampler); return guid === undefined ? [] : [guid]; }), ), modules: unique([ ...(material.passes ?? []).map((pass) => pass.program.module), ...(material.surface === undefined ? [] : [material.surface.module]), ]), }; } export function createMaterialArtifactDigest(bytes: Uint8Array): string { return `sha256:${bytesToHex(sha256(bytes))}`; } function jsonValue(value: unknown): unknown { if (value instanceof Uint8Array) return [...value]; if (Array.isArray(value)) return value.map(jsonValue); if (value !== null && typeof value === 'object') { return Object.fromEntries( Object.entries(value) .filter(([, entry]) => entry !== undefined) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, entry]) => [key, jsonValue(entry)]), ); } return value; } export function createMaterialCookIdentity(input: MaterialCookIdentityInput): MaterialCookIdentity { const cookIdentity = createMaterialArtifactDigest( new TextEncoder().encode( JSON.stringify( jsonValue({ materialContractDigest: input.materialContractDigest, sourceRevision: input.sourceRevision, sourceClosureDigest: input.sourceClosureDigest, layoutIdentity: input.layoutIdentity, programIdentity: input.programIdentity, pipelineIdentity: input.pipelineIdentity, compilerFingerprint: input.compilerFingerprint, wasm: input.wasm, artifactDigest: input.artifactDigest, }), ), ), ); return { ...input, cookIdentity }; } export function serializeCookedMaterialRecord(record: CookedMaterialRecord): string { return JSON.stringify(jsonValue(record)); } export function serializeMaterialCookReceipt(receipt: MaterialCookReceipt): string { return JSON.stringify(jsonValue({ ...receipt, sourceClosure: unique(receipt.sourceClosure) })); } function invalid(field: string, actual?: unknown): Result { return err({ code: 'material-cook-record-invalid', expected: 'a complete material-cook/4 record with layered identity and provenance', hint: 're-cook the material and publish its record, artifact, references, and receipt together', detail: { field, ...(actual === undefined ? {} : { actual }), action: 'inspect the named field and recook the material generation', }, }); } function normalizeArtifactBytes(value: unknown): Uint8Array | undefined { if (Array.isArray(value)) { return value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255) ? Uint8Array.from(value) : undefined; } if (!ArrayBuffer.isView(value)) return undefined; const view = value as ArrayBufferView; return new Uint8Array(view.buffer, view.byteOffset, view.byteLength).slice(); } const IDENTITY_FIELDS = [ 'materialContractDigest', 'sourceRevision', 'sourceClosureDigest', 'layoutIdentity', 'programIdentity', 'pipelineIdentity', 'materialPublicationIdentity', 'cookIdentity', 'compilerFingerprint', 'artifactDigest', ] as const; function isGeneration(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1; } function validateIdentity(value: unknown): Result { if (value === null || typeof value !== 'object') return invalid('receipt.identity', value); const candidate = value as Record; for (const field of IDENTITY_FIELDS) { if (typeof candidate[field] !== 'string' || candidate[field].length === 0) { return invalid(`receipt.identity.${field}`, candidate[field]); } } if (candidate.wasm === null || typeof candidate.wasm !== 'object') { return invalid('receipt.identity.wasm', candidate.wasm); } const wasm = candidate.wasm as Record; for (const field of ['sourceContentKey', 'artifactSha256', 'glueSha256']) { if (typeof wasm[field] !== 'string' || wasm[field].length === 0) { return invalid(`receipt.identity.wasm.${field}`, wasm[field]); } } for (const field of ['valueGeneration', 'dependencyGeneration', 'cookGeneration']) { if (!isGeneration(candidate[field])) return invalid(`receipt.identity.${field}`, candidate[field]); } return ok({ ...candidate, wasm: wasm as unknown as MaterialCookWasmProvenance, } as MaterialCookIdentity); } export function validateMaterialCookReceipt( value: unknown, expected: MaterialCookIdentityExpectation = {}, ): Result { if (value === null || typeof value !== 'object') return invalid('receipt'); const candidate = value as Record; if (candidate.schemaVersion !== 'material-cook/4') return invalid('receipt.schemaVersion', candidate.schemaVersion); const identityResult = validateIdentity(candidate.identity); if (!identityResult.ok) return identityResult; const identity = identityResult.value; if (candidate.derivedInterface === null || typeof candidate.derivedInterface !== 'object') { return invalid('receipt.derivedInterface', candidate.derivedInterface); } const derivedInterface = candidate.derivedInterface as Record; if (derivedInterface.layoutIdentity !== identity.layoutIdentity) { return invalid('receipt.derivedInterface.layoutIdentity', derivedInterface.layoutIdentity); } if ( derivedInterface.layerPlanIdentity !== undefined && (typeof derivedInterface.layerPlanIdentity !== 'string' || derivedInterface.layerPlanIdentity.length === 0) ) { return invalid( 'receipt.derivedInterface.layerPlanIdentity', derivedInterface.layerPlanIdentity, ); } for (const field of ['sourceClosure', 'profile', 'compilerVersion']) { const fieldValue = candidate[field]; if ( (field === 'sourceClosure' && !Array.isArray(fieldValue)) || (field !== 'sourceClosure' && typeof fieldValue !== 'string') ) { return invalid(`receipt.${field}`, fieldValue); } } const sourceClosure = candidate.sourceClosure; if (Array.isArray(sourceClosure) && sourceClosure.some((path) => typeof path !== 'string')) { return invalid('receipt.sourceClosure', sourceClosure); } if ( expected.layoutIdentity !== undefined && identity.layoutIdentity !== expected.layoutIdentity ) { return invalid('receipt.identity.layoutIdentity', identity.layoutIdentity); } if ( expected.artifactDigest !== undefined && identity.artifactDigest !== expected.artifactDigest ) { return invalid('receipt.identity.artifactDigest', identity.artifactDigest); } if (expected.inputDigest !== undefined && identity.cookIdentity !== expected.inputDigest) { return invalid('receipt.identity.cookIdentity', identity.cookIdentity); } return ok({ schemaVersion: 'material-cook/4', sourceClosure: candidate.sourceClosure as string[], profile: candidate.profile as string, compilerVersion: candidate.compilerVersion as string, identity, derivedInterface: { layoutIdentity: derivedInterface.layoutIdentity as string, ...(derivedInterface.layerPlanIdentity === undefined ? {} : { layerPlanIdentity: derivedInterface.layerPlanIdentity as string }), }, }); } export function validateCookedMaterialRecord( value: unknown, ): Result { if (value === null || typeof value !== 'object') return invalid('record'); const candidate = value as Record; if (candidate.schemaVersion !== 'material-cook/4') return invalid('schemaVersion', candidate.schemaVersion); if (typeof candidate.guid !== 'string' || !candidate.guid) return invalid('guid'); if (candidate.materialGuid !== undefined && typeof candidate.materialGuid !== 'string') return invalid('materialGuid'); if ( candidate.publicationGeneration !== undefined && !isGeneration(candidate.publicationGeneration) ) return invalid('publicationGeneration', candidate.publicationGeneration); if (candidate.specializationKey !== undefined && typeof candidate.specializationKey !== 'string') return invalid('specializationKey'); if ('artifact' in candidate || 'variants' in candidate || 'variantContext' in candidate) return invalid('programs', 'legacy single-artifact publication'); if (candidate.artifactDigest !== undefined && typeof candidate.artifactDigest !== 'string') return invalid('artifactDigest'); if ( candidate.sourceClosure !== undefined && (!Array.isArray(candidate.sourceClosure) || candidate.sourceClosure.some((path) => typeof path !== 'string')) ) return invalid('sourceClosure'); if (candidate.parameterContract !== undefined) { if (candidate.parameterContract === null || typeof candidate.parameterContract !== 'object') return invalid('parameterContract'); const parameterContract = candidate.parameterContract as Record; if (!Array.isArray(parameterContract.parameters)) return invalid('parameterContract.parameters'); if ( parameterContract.values === null || typeof parameterContract.values !== 'object' || Array.isArray(parameterContract.values) ) return invalid('parameterContract.values'); } if (candidate.resolved === null || typeof candidate.resolved !== 'object') return invalid('resolved'); if (candidate.refs === null || typeof candidate.refs !== 'object') return invalid('refs'); if (candidate.receipt === null || typeof candidate.receipt !== 'object') return invalid('receipt'); const resolved = candidate.resolved as Record; if (!Array.isArray(resolved.passes)) return invalid('resolved.passes', resolved.passes); if (!Array.isArray(resolved.parameters)) return invalid('resolved.parameters', resolved.parameters); if ( resolved.values === null || typeof resolved.values !== 'object' || Array.isArray(resolved.values) ) return invalid('resolved.values', resolved.values); if (resolved.surface !== undefined && !isMaterialSurfaceDeclaration(resolved.surface)) return invalid('resolved.surface', resolved.surface); const passes = resolved.passes as MaterialPass[]; const passNames = new Set(); for (const [index, pass] of passes.entries()) { if ( pass === null || typeof pass !== 'object' || typeof pass.name !== 'string' || !pass.name || passNames.has(pass.name) || pass.program === null || typeof pass.program !== 'object' || typeof pass.program.module !== 'string' ) return invalid(`resolved.passes[${index}]`); passNames.add(pass.name); } if (!Array.isArray(candidate.programs) || candidate.programs.length === 0) return invalid('programs'); const programs: MaterialCookProgram[] = []; const programKeys = new Set(); const selections = new Set(); const submissionSelections = new Map>(); let modernPublication = false; let legacySelectionField: string | undefined; const selectedPasses = new Set(); for (const [index, entry] of candidate.programs.entries()) { const field = `programs[${index}]`; if (entry === null || typeof entry !== 'object') return invalid(field); const program = entry as Record; if ( typeof program.specializationKey !== 'string' || !program.specializationKey || programKeys.has(program.specializationKey) ) return invalid(`${field}.specializationKey`); programKeys.add(program.specializationKey); if (program.artifact === null || typeof program.artifact !== 'object') return invalid(`${field}.artifact`); const artifact = program.artifact as Record; const bytes = normalizeArtifactBytes(artifact.bytes); if ( artifact.mediaType !== 'text/wgsl' || typeof artifact.path !== 'string' || !artifact.path || typeof artifact.digest !== 'string' || !artifact.digest || bytes === undefined ) return invalid(`${field}.artifact`); if (!Array.isArray(program.selections) || program.selections.length === 0) return invalid(`${field}.selections`); const programSelections: MaterialCookProgram['selections'][number][] = []; for (const [selectionIndex, rawSelection] of program.selections.entries()) { const selectionField = `${field}.selections[${selectionIndex}]`; if (rawSelection === null || typeof rawSelection !== 'object') return invalid(selectionField); const selection = rawSelection as Record; if (typeof selection.pass !== 'string' || !passNames.has(selection.pass)) return invalid(selectionField); const context = validateMaterialCookProgramContext(selection.context); if (!context.ok) return invalid( `${selectionField}.${context.error.detail.field}`, context.error.detail.actual, ); if (context.value.pipeline === 'ray') { if (selection.address !== undefined || selection.abi !== undefined) return invalid( selectionField, 'Surface derivatives have no raster submission address or ABI', ); const entry = context.value.pass === 'ray-hit' ? 'cs_surface' : 'vs_card'; if (selection.entry !== entry) return invalid( `${selectionField}.entry`, `${context.value.pass} Surface entry must be ${entry}`, ); const key = JSON.stringify([selection.pass, materialProgramContextKey(context.value)]); if (selections.has(key)) return invalid(selectionField, 'ambiguous Pass/context selection'); selections.add(key); programSelections.push({ pass: selection.pass, context: context.value, entry, }); continue; } const address = selection.address === undefined ? 'direct' : selection.address; if (address !== 'direct' && address !== 'scene-index') return invalid(`${selectionField}.address`, selection.address); const hasAddressFacts = selection.address !== undefined || selection.entry !== undefined || selection.abi !== undefined; modernPublication ||= hasAddressFacts; if (!hasAddressFacts) legacySelectionField ??= selectionField; const rawEntry = selection.entry; const entry = typeof rawEntry === 'string' ? rawEntry : undefined; if (hasAddressFacts && selection.address === undefined) return invalid( `${selectionField}.address`, 'modern ABI selections require an explicit address', ); if (hasAddressFacts && (entry === undefined || entry.length === 0)) return invalid(`${selectionField}.entry`, rawEntry); if (hasAddressFacts && !isMaterialProgramAbi(selection.abi)) return invalid(`${selectionField}.abi`, selection.abi); if (hasAddressFacts) { const abi = selection.abi as MaterialProgramAbi; const expectedEntry = address === 'direct' ? abi.directEntry : abi.sceneIndexEntry; if (entry !== expectedEntry) return invalid(`${selectionField}.entry`, 'entry does not match published ABI'); if (abi.sceneIndexEntry !== undefined) { const submissionKey = JSON.stringify([ selection.pass, materialProgramContextKey(context.value), abi.vertexInputs.some((input) => input.semantic === 'color'), ]); const addresses = submissionSelections.get(submissionKey) ?? new Set(); addresses.add(address); submissionSelections.set(submissionKey, addresses); } } const key = JSON.stringify([ selection.pass, materialProgramContextKey(context.value), address, hasAddressFacts && (selection.abi as MaterialProgramAbi).vertexInputs.some( (input) => input.semantic === 'color', ), ]); if (selections.has(key)) return invalid(selectionField, 'ambiguous Pass/context selection'); selections.add(key); selectedPasses.add(selection.pass); programSelections.push({ pass: selection.pass, context: context.value, ...(selection.address === undefined ? {} : { address }), ...(entry === undefined ? {} : { entry }), ...(selection.abi === undefined ? {} : { abi: selection.abi as MaterialProgramAbi }), }); } programs.push({ specializationKey: program.specializationKey, artifact: { mediaType: 'text/wgsl', path: artifact.path, digest: artifact.digest, bytes }, selections: programSelections, }); } if (modernPublication && legacySelectionField !== undefined) { return invalid( legacySelectionField, 'modern material publications require address, entry, and ABI facts on every selection', ); } for (const [selectionKey, addresses] of submissionSelections) { if (addresses.size !== 2) { return invalid('programs.selections', `incomplete submission address pair: ${selectionKey}`); } } if ([...passNames].some((pass) => !selectedPasses.has(pass))) return invalid('programs.selections', 'unpublished Pass'); const manifestDigest = createMaterialProgramSetDigest(programs, passes); const receiptResult = validateMaterialCookReceipt(candidate.receipt, { artifactDigest: manifestDigest, }); if (!receiptResult.ok) return receiptResult; let expectedLayerPlanIdentity: string | undefined; try { expectedLayerPlanIdentity = materialLayerPlanIdentity({ resolved: resolved as unknown as CookedMaterialRecord['resolved'], }); } catch (error) { return invalid('resolved.layerPlanIdentity', error instanceof Error ? error.message : error); } if ( expectedLayerPlanIdentity !== undefined && receiptResult.value.derivedInterface.layerPlanIdentity !== expectedLayerPlanIdentity ) { return invalid( 'receipt.derivedInterface.layerPlanIdentity', receiptResult.value.derivedInterface.layerPlanIdentity, ); } if (candidate.artifactDigest !== undefined && candidate.artifactDigest !== manifestDigest) return invalid('artifactDigest', candidate.artifactDigest); if ( candidate.publicationGeneration !== undefined && receiptResult.value.identity.cookGeneration !== candidate.publicationGeneration ) return invalid('receipt.identity.cookGeneration', receiptResult.value.identity.cookGeneration); return ok({ ...candidate, programs, receipt: receiptResult.value, } as unknown as CookedMaterialRecord); } export function projectCookedMaterialRecord( record: CookedMaterialRecord, ): Omit { return { resolved: record.resolved, refs: record.refs, programs: record.programs, receipt: record.receipt, schemaVersion: record.schemaVersion, }; } /** Canonical context key shared by publication validation and runtime selection. */ export function materialProgramContextKey(context: MaterialCookProgramContext): string { return JSON.stringify(jsonValue(context)); } /** One pass-domain projection for both the cooker and runtime selector. */ export function materialProgramContextForPass( context: MaterialCookRasterContext, lightMode: string, ): MaterialCookRasterContext { const pass = /shadow/i.test(lightMode) ? 'shadow' : /depth/i.test(lightMode) ? 'depth' : 'forward'; const { visibleSurface, ...base } = context; return { ...base, pipeline: /deferred|gbuffer/i.test(lightMode) ? 'deferred' : context.pipeline, pass, ...(pass === 'forward' && visibleSurface === true ? { visibleSurface: true } : {}), }; } export function validateMaterialCookProgramContext( value: unknown, ): Result { if (value === null || typeof value !== 'object' || Array.isArray(value)) return invalid('context', value); const context = value as Record; if (context.pipeline === 'ray') { const fields = { backend: ['webgpu', 'wgpu-native'], capability: ['storage-buffer'], pipeline: ['ray'], geometry: ['mesh'], pass: ['ray-hit', 'card-capture'], profile: ['forgeax-material-ray-v1'], toolchain: ['naga-oil'], instrumentation: ['none'], }; for (const field of Object.keys(context)) if (!(field in fields)) return invalid(`context.${field}`, context[field]); for (const [field, allowed] of Object.entries(fields)) if (!allowed.includes(context[field] as string)) return invalid(`context.${field}`, context[field]); return ok({ ...context } as unknown as MaterialCookRayContext); } const fields = { backend: ['webgpu', 'webgl2', 'wgpu-native'], capability: ['storage-buffer', 'storage-buffer-atmosphere', 'uniform-fallback'], pipeline: ['forward', 'deferred'], geometry: ['mesh', 'skinned', 'sprite', 'sprite-instances', 'terrain'], pass: ['forward', 'shadow', 'depth'], profile: ['forgeax-material-wgsl-v1'], toolchain: ['naga-oil'], instrumentation: ['none', 'validation'], }; for (const field of Object.keys(context)) { if (field === 'visibleSurface') { if (context[field] !== true) return invalid(`context.${field}`, context[field]); } else if (!(field in fields)) return invalid(`context.${field}`, context[field]); } for (const [field, allowed] of Object.entries(fields)) { if (!allowed.includes(context[field] as string)) return invalid(`context.${field}`, context[field]); } return ok({ ...context } as unknown as MaterialCookProgramContext); } /** Covers every selection and artifact while excluding transport copies of the bytes. */ export function createMaterialProgramSetDigest( programs: readonly MaterialCookProgram[], passes: readonly MaterialPass[], ): string { const manifest = { passes, programs: programs .map((program) => ({ specializationKey: program.specializationKey, artifact: { path: program.artifact.path, mediaType: program.artifact.mediaType, digest: program.artifact.digest, }, selections: [...program.selections].sort((a, b) => JSON.stringify(jsonValue(a)).localeCompare(JSON.stringify(jsonValue(b))), ), })) .sort((a, b) => a.specializationKey.localeCompare(b.specializationKey)), }; return createMaterialArtifactDigest( new TextEncoder().encode(JSON.stringify(jsonValue(manifest))), ); }