diff --git a/src/core/scene/scene-process/service/preview/material-preview-states.ts b/src/core/scene/scene-process/service/preview/material-preview-states.ts new file mode 100644 index 000000000..2262f9166 --- /dev/null +++ b/src/core/scene/scene-process/service/preview/material-preview-states.ts @@ -0,0 +1,42 @@ +/** + * Removes empty-string `phase` overrides from material pipeline states. + * + * Inspector dumps encode an unset effect pass.phase as `PassStatesEditor.phase = ''`. + * `Pass.fillPipelineInfo` treats any defined phase as an override, and + * `getPhaseID('')` registers a unique unused render phase. The preview camera + * never draws that phase, so the mesh disappears after apply. + * + * @param states Material `_states` array, a single override record, or unrelated input. + * @returns Whether any empty phase was removed. + * + * @example + * ```ts + * const states = [{ phase: '', primitive: 7 }, { phase: 'forward-add' }]; + * omitEmptyMaterialPhaseOverrides(states); + * // states[0] has no phase; states[1].phase is still 'forward-add' + * ``` + */ +export function omitEmptyMaterialPhaseOverrides(states: unknown): boolean { + if (Array.isArray(states)) { + let mutated = false; + for (const state of states) { + if (omitEmptyPhaseFromRecord(state)) { + mutated = true; + } + } + return mutated; + } + return omitEmptyPhaseFromRecord(states); +} + +function omitEmptyPhaseFromRecord(state: unknown): boolean { + if (!state || typeof state !== 'object' || Array.isArray(state)) { + return false; + } + const record = state as Record; + if (record.phase !== '') { + return false; + } + delete record.phase; + return true; +} diff --git a/src/core/scene/scene-process/service/preview/material-preview.ts b/src/core/scene/scene-process/service/preview/material-preview.ts index 7de433e56..a4aae4753 100644 --- a/src/core/scene/scene-process/service/preview/material-preview.ts +++ b/src/core/scene/scene-process/service/preview/material-preview.ts @@ -84,6 +84,7 @@ const transientMaterialOverridePatchKey = Symbol.for('cocos.cli.materialPreview. import type { IMaterialPreviewInstance } from '../../../common/preview'; import { loadPreviewAsset } from './asset-reload'; +import { omitEmptyMaterialPhaseOverrides } from './material-preview-states'; function collectTextureProperties(value: any, out: any[]) { if (!value) return; @@ -203,6 +204,10 @@ function applyMaterialRecord(material: any, key: '_defines' | '_states', overrid applyAt(passIdx); } + if (key === '_states') { + omitEmptyMaterialPhaseOverrides(records); + } + material._update?.(true); } @@ -238,6 +243,13 @@ function installTransientMaterialOverridePatch() { }; } +/** Drops dump-default `phase: ''` and rebuilds passes so the preview camera can still draw. */ +function rebuildPassesWithoutEmptyPhase(material: Material) { + if (omitEmptyMaterialPhaseOverrides((material as any)._states)) { + (material as any)._update?.(true); + } +} + export class MaterialPreview extends InteractivePreview implements IMaterialPreviewInstance { private lightComp!: DirectionalLight; private modelComp!: MeshRenderer; @@ -308,8 +320,23 @@ export class MaterialPreview extends InteractivePreview implements IMaterialPrev this._modelNode = this.modelComp.node; } + /* + ```mermaid + sequenceDiagram + participant Panel as MaterialPanel apply + participant Preview as MaterialPreview.setMaterial + participant Material as cc.Material + participant Pass as cc.Pass + Panel->>Preview: dump-built Material (_states.phase === "") + Preview->>Material: omit empty phase, _update + Material->>Pass: fillPipelineInfo without phase override + Pass-->>Preview: default phase (camera can draw) + Preview->>Preview: wrap MaterialInstance and assign + ``` + */ public setMaterial(material: Material | null, force = false) { if (material && (force || material !== this.material)) { + rebuildPassesWithoutEmptyPhase(material); const comp = this.modelComp; const _matInsInfo = { parent: material, diff --git a/src/core/scene/test/material-preview-states.test.ts b/src/core/scene/test/material-preview-states.test.ts new file mode 100644 index 000000000..19bae9631 --- /dev/null +++ b/src/core/scene/test/material-preview-states.test.ts @@ -0,0 +1,36 @@ +import { omitEmptyMaterialPhaseOverrides } from '../scene-process/service/preview/material-preview-states'; + +describe('omitEmptyMaterialPhaseOverrides', () => { + it('removes empty-string phase from dump states so apply uses the effect default', () => { + const states = [ + { phase: '', primitive: 7 }, + { primitive: 7 }, + ]; + const single = { phase: '', rasterizerState: { cullMode: 2 } }; + + expect(omitEmptyMaterialPhaseOverrides(states)).toBe(true); + expect(states[0]).toEqual({ primitive: 7 }); + expect(states[1]).toEqual({ primitive: 7 }); + + expect(omitEmptyMaterialPhaseOverrides(single)).toBe(true); + expect(single).toEqual({ rasterizerState: { cullMode: 2 } }); + expect(omitEmptyMaterialPhaseOverrides(states)).toBe(false); + }); + + it('keeps named or numeric phase overrides and ignores unrelated input', () => { + const states = [ + { phase: 'forward-add', primitive: 7 }, + { phase: 'default' }, + { phase: 16 }, + ]; + + expect(omitEmptyMaterialPhaseOverrides(states)).toBe(false); + expect(states[0].phase).toBe('forward-add'); + expect(states[1].phase).toBe('default'); + expect(states[2].phase).toBe(16); + expect(omitEmptyMaterialPhaseOverrides(null)).toBe(false); + expect(omitEmptyMaterialPhaseOverrides(undefined)).toBe(false); + expect(omitEmptyMaterialPhaseOverrides('')).toBe(false); + expect(omitEmptyMaterialPhaseOverrides([{ phase: null }])).toBe(false); + }); +});