UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

958 lines (858 loc) 35.7 kB
import { BufferGeometry, Camera, Color, Euler,Group, Material, Object3D, Scene, Texture, Vector2, Vector3, Vector4, WebGLRenderer } from "three"; // @TODO: we need to detect objects with materials both transparent and NOT transparent. These need to be updated in scene.onBeforeRender to have correct renderlists /** * Valid types that can be used as material property overrides */ type MaterialPropertyType = number | number[] | Color | Texture | Vector2 | Vector3 | Vector4 | null | Euler; /** * Defines offset and repeat transformations for texture coordinates */ export interface TextureTransform { /** UV offset applied to the texture */ offset?: Vector2; /** UV repeat/scale applied to the texture */ repeat?: Vector2; } /** * Represents a single material property override with optional texture transformation * @template T The type of the property value */ export interface PropertyBlockOverride<T extends MaterialPropertyType = MaterialPropertyType> { /** The name of the material property to override (e.g., "color", "map", "roughness") */ name: string; /** The value to set for this property */ value: T; /** Optional texture coordinate transformation (only used when value is a Texture) */ textureTransform?: TextureTransform; } /** * Utility type that extracts only non-function property names from a type * @template T The type to extract property names from */ type NonFunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T]; /** * Centralized registry for all property block related data. * Uses WeakMaps to allow automatic garbage collection when objects are destroyed. * @internal */ class PropertyBlockRegistry { // Map from object to its property block private objectToBlock = new WeakMap<Object3D, MaterialPropertyBlock>(); // Track which materials belong to which property block (to prevent applying to wrong materials) // Use WeakSet for automatic cleanup when materials are garbage collected // private objectToMaterials = new WeakMap<Object3D, WeakSet<Material>>(); // Track which meshes have callbacks for which property block owners private meshToOwners = new WeakMap<Object3D, Set<Object3D>>(); // Track original callback functions for cleanup (reserved for future use) private meshToOriginalCallbacks = new WeakMap<Object3D, { onBeforeRender?: ObjectRenderCallback; onAfterRender?: ObjectRenderCallback; }>(); getBlock(object: Object3D): MaterialPropertyBlock | undefined { return this.objectToBlock.get(object); } setBlock(object: Object3D, block: MaterialPropertyBlock): void { this.objectToBlock.set(object, block); } deleteBlock(object: Object3D): void { this.objectToBlock.delete(object); // this.objectToMaterials.delete(object); } // addMaterial(object: Object3D, material: Material): void { // let materials = this.objectToMaterials.get(object); // if (!materials) { // materials = new WeakSet(); // this.objectToMaterials.set(object, materials); // } // materials.add(material); // } // hasMaterial(object: Object3D, material: Material): boolean { // return this.objectToMaterials.get(object)?.has(material) ?? false; // } isHooked(mesh: Object3D, owner: Object3D): boolean { return this.meshToOwners.get(mesh)?.has(owner) ?? false; } addHook(mesh: Object3D, owner: Object3D): void { let owners = this.meshToOwners.get(mesh); if (!owners) { owners = new Set(); this.meshToOwners.set(mesh, owners); } owners.add(owner); } removeHook(mesh: Object3D, owner: Object3D): void { const owners = this.meshToOwners.get(mesh); if (owners) { owners.delete(owner); if (owners.size === 0) { this.meshToOwners.delete(mesh); } } } getOriginalCallbacks(mesh: Object3D): { onBeforeRender?: ObjectRenderCallback; onAfterRender?: ObjectRenderCallback } | undefined { return this.meshToOriginalCallbacks.get(mesh); } setOriginalCallbacks(mesh: Object3D, callbacks: { onBeforeRender?: ObjectRenderCallback; onAfterRender?: ObjectRenderCallback }): void { this.meshToOriginalCallbacks.set(mesh, callbacks); } } const registry = new PropertyBlockRegistry(); /** * MaterialPropertyBlock allows per-object material property overrides without creating new material instances. * This is useful for rendering multiple objects with the same base material but different properties * (e.g., different colors, textures, or shader parameters). * * ## How Property Blocks Work * * **Important**: Overrides are registered on the **Object3D**, not on the material. * This means: * - If you change the object's material, the overrides will still be applied to the new material * - Multiple objects can share the same material but have different property overrides * - If you don't want overrides applied after changing a material, you must remove them using {@link removeOveride}, {@link clearAllOverrides}, or {@link dispose} * * The property block system works by: * - Temporarily applying overrides in onBeforeRender * - Restoring original values in onAfterRender * - Managing shader defines and program cache keys for correct shader compilation * - Supporting texture coordinate transforms per object * * ## Common Use Cases * * - **Lightmaps**: Apply unique lightmap textures to individual objects sharing the same material * - **Reflection Probes**: Apply different environment maps per object for localized reflections * - **See-through effects**: Temporarily override transparency/transmission properties for X-ray effects * * ## Getting a MaterialPropertyBlock * * **Important**: Do not use the constructor directly. Instead, use the static {@link MaterialPropertyBlock.get} method: * * ```typescript * const block = MaterialPropertyBlock.get(myMesh); * ``` * * This method will either return an existing property block or create a new one if it doesn't exist. * It automatically: * - Creates the property block instance * - Registers it in the internal registry * - Attaches the necessary render callbacks to the object * - Handles Groups by applying overrides to all child meshes * * @example Basic usage * ```typescript * // Get or create a property block for an object * const block = MaterialPropertyBlock.get(myMesh); * * // Override the color property * block.setOverride("color", new Color(1, 0, 0)); * * // Override a texture with custom UV transform (useful for lightmaps) * block.setOverride("lightMap", myLightmapTexture, { * offset: new Vector2(0.5, 0.5), * repeat: new Vector2(2, 2) * }); * * // Set a shader define * block.setDefine("USE_CUSTOM_FEATURE", 1); * ``` * * @example Material swapping behavior * ```typescript * const mesh = new Mesh(geometry, materialA); * const block = MaterialPropertyBlock.get(mesh); * block.setOverride("color", new Color(1, 0, 0)); * * // The color override is red for materialA * * // Swap the material - overrides persist and apply to the new material! * mesh.material = materialB; * // The color override is now red for materialB too * * // If you don't want overrides on the new material, remove them: * block.clearAllOverrides(); // Remove all overrides * // or * block.removeOveride("color"); // Remove specific override * // or * block.dispose(); // Remove the entire property block * ``` * * @example Lightmap usage * ```typescript * const block = MaterialPropertyBlock.get(mesh); * block.setOverride("lightMap", lightmapTexture); * block.setOverride("lightMapIntensity", 1.5); * ``` * * @example See-through effect * ```typescript * const block = MaterialPropertyBlock.get(mesh); * block.setOverride("transparent", true); * block.setOverride("opacity", 0.3); * ``` * * @template T The material type this property block is associated with */ export class MaterialPropertyBlock<T extends Material = Material> { private _overrides: PropertyBlockOverride[] = []; private _defines: Record<string, string | number | boolean> = {}; private _object: Object3D | null = null; /** The object this property block is attached to */ get object(): Object3D | null { return this._object; } /** * Creates a new MaterialPropertyBlock * @param object The object this property block is for (optional) */ protected constructor(object: Object3D | null = null) { this._object = object; } /** * Gets or creates a MaterialPropertyBlock for the given object. * This is the recommended way to obtain a property block instance. * * @template T The material type * @param object The object to get/create a property block for * @returns The MaterialPropertyBlock associated with this object * * @example * ```typescript * const block = MaterialPropertyBlock.get(myMesh); * block.setOverride("roughness", 0.5); * ``` */ static get<T extends Material = Material>(object: Object3D): MaterialPropertyBlock<T> { let block = registry.getBlock(object); if (!block) { block = new MaterialPropertyBlock(object); registry.setBlock(object, block); attachPropertyBlockToObject(object, block); } return block as MaterialPropertyBlock<T>; } /** * Checks if an object has any property overrides * @param object The object to check * @returns True if the object has a property block with overrides */ static hasOverrides(object: Object3D): boolean { const block = registry.getBlock(object); return block ? block.hasOverrides() : false; } /** * Disposes this property block and cleans up associated resources. * After calling dispose, this property block should not be used. */ dispose() { if (this._object) { registry.deleteBlock(this._object); // TODO: Add cleanup for hooked meshes } this._overrides = []; this._object = null; } /** * Sets or updates a material property override. * The override will be applied to the material during rendering. * * @param name The name of the material property to override (e.g., "color", "map", "roughness") * @param value The value to set * @param textureTransform Optional UV transform (only used when value is a Texture) * * @example * ```typescript * // Override a simple property * block.setOverride("roughness", 0.8); * * // Override a color * block.setOverride("color", new Color(0xff0000)); * * // Override a texture with UV transform * block.setOverride("map", texture, { * offset: new Vector2(0, 0), * repeat: new Vector2(2, 2) * }); * ``` */ setOverride<K extends NonFunctionPropertyNames<T>>(name: K, value: T[K], textureTransform?: TextureTransform): void; setOverride(name: string, value: MaterialPropertyType, textureTransform?: TextureTransform): void; setOverride(name: string, value: MaterialPropertyType, textureTransform?: TextureTransform): void { const existing = this._overrides.find(o => o.name === name); if (existing) { existing.value = value; existing.textureTransform = textureTransform; } else { this._overrides.push({ name, value, textureTransform }); } } /** * Gets the override for a specific property with type-safe value inference * @param name The property name to get * @returns The PropertyBlockOverride with correctly typed value if it exists, undefined otherwise * * @example * ```typescript * const block = MaterialPropertyBlock.get<MeshStandardMaterial>(mesh); * * // Value is inferred as number | undefined * const roughness = block.getOverride("roughness")?.value; * * // Value is inferred as Color | undefined * const color = block.getOverride("color")?.value; * * // Value is inferred as Texture | null | undefined * const map = block.getOverride("map")?.value; * * // Explicitly specify the type for properties not on the base material type * const transmission = block.getOverride<number>("transmission")?.value; * * // Or use a more specific material type * const physicalBlock = block as MaterialPropertyBlock<MeshPhysicalMaterial>; * const transmissionTyped = physicalBlock.getOverride("transmission")?.value; // number * ``` */ getOverride<K extends NonFunctionPropertyNames<T>>(name: K): PropertyBlockOverride<T[K] & MaterialPropertyType> | undefined; getOverride<V extends MaterialPropertyType = MaterialPropertyType>(name: string): PropertyBlockOverride<V> | undefined; getOverride(name: string): PropertyBlockOverride | undefined { return this._overrides.find(o => o.name === name); } /** * Removes a specific property override. * After removal, the material will use its original property value for this property. * * @param name The property name to remove the override for * * @example * ```typescript * const block = MaterialPropertyBlock.get(mesh); * * // Set some overrides * block.setOverride("color", new Color(1, 0, 0)); * block.setOverride("roughness", 0.5); * block.setOverride("lightMap", lightmapTexture); * * // Remove a specific override - the material will now use its original color * block.removeOveride("color"); * * // Other overrides (roughness, lightMap) remain active * ``` */ removeOveride<K extends NonFunctionPropertyNames<T>>(name: K | ({} & string)): void { const index = this._overrides.findIndex(o => o.name === name); if (index >= 0) { this._overrides.splice(index, 1); } } /** * Removes all property overrides from this block. * After calling this, the material will use its original values for all properties. * * **Note**: This does NOT remove shader defines. Use {@link clearDefine} or {@link dispose} for that. * * @example Remove all overrides but keep the property block * ```typescript * const block = MaterialPropertyBlock.get(mesh); * * // Set multiple overrides * block.setOverride("color", new Color(1, 0, 0)); * block.setOverride("roughness", 0.5); * block.setOverride("lightMap", lightmapTexture); * * // Later, remove all overrides at once * block.clearAllOverrides(); * * // The material now uses its original values * // The property block still exists and can be reused with new overrides * ``` * * @example Temporarily disable all overrides * ```typescript * const block = MaterialPropertyBlock.get(mesh); * * // Save current overrides if you want to restore them later * const savedOverrides = [...block.overrides]; * * // Clear all overrides temporarily * block.clearAllOverrides(); * * // Do some rendering without overrides... * * // Restore overrides * savedOverrides.forEach(override => { * block.setOverride(override.name, override.value, override.textureTransform); * }); * ``` * * @see {@link removeOveride} - To remove a single override * @see {@link dispose} - To completely remove the property block and clean up resources */ clearAllOverrides(): void { this._overrides = []; } /** * Gets all property overrides as a readonly array * @returns Array of all property overrides */ get overrides(): readonly PropertyBlockOverride[] { return this._overrides; } /** * Checks if this property block has any overrides * @returns True if there are any overrides set */ hasOverrides(): boolean { return this._overrides.length > 0; } /** * Set a shader define that will be included in the program cache key. * This allows different objects sharing the same material to have different shader programs. * * Defines affect shader compilation and are useful for enabling/disabling features per-object. * * @param name The define name (e.g., "USE_LIGHTMAP", "ENABLE_REFLECTIONS") * @param value The define value (typically a boolean, number, or string) * * @example * ```typescript * // Enable a feature for this specific object * block.setDefine("USE_CUSTOM_SHADER", true); * block.setDefine("QUALITY_LEVEL", 2); * ``` */ setDefine(name: string, value: string | number | boolean): void { this._defines[name] = value; } /** * Remove a shader define * @param name The define name to remove */ clearDefine(name: string): void { this._defines[name] = undefined as any; } /** * Get all defines set on this property block * @returns A readonly record of all defines */ getDefines(): Readonly<Record<string, string | number | boolean>> { return this._defines; } /** * Generates a cache key based on the current overrides and defines. * This key is used internally to ensure correct shader program selection * when objects share materials but have different property blocks. * * @returns A string representing the current state of this property block * @internal */ getCacheKey(): string { const parts: string[] = []; // Add defines to cache key const defineKeys = Object.keys(this._defines).sort(); for (const key of defineKeys) { const value = this._defines[key]; if (value !== undefined) { parts.push(`d:${key}=${value}`); } } // Add overrides to cache key for (const o of this._overrides) { if (o.value === null) continue; let val = ""; if (o.value instanceof Texture) { val = (o.value as any).uuid || "texture"; if (o.textureTransform) { const t = o.textureTransform; if (t.offset) val += `;to:${t.offset.x},${t.offset.y}`; if (t.repeat) val += `;tr:${t.repeat.x},${t.repeat.y}`; } } else if (Array.isArray(o.value)) { val = o.value.join(","); } else if (o.value && typeof o.value === "object" && "r" in o.value) { const c = o.value as any; val = `${c.r},${c.g},${c.b},${c.a !== undefined ? c.a : ""}`; } else if (o.value && typeof o.value === "object" && "x" in o.value) { // Vector2, Vector3, Vector4 const v = o.value as any; val = `${v.x},${v.y}${v.z !== undefined ? `,${v.z}` : ""}${v.w !== undefined ? `,${v.w}` : ""}`; } else { val = String(o.value); } parts.push(`${o.name}=${val}`); } return parts.join(";"); } } /** * Symbol used to store original material values on the material object * @internal */ const $originalValues = Symbol("originalValues"); /** * Stores an original material property value before override * @internal */ interface OriginalValue { name: string; value: unknown; } /** * Stores saved texture transform state for restoration * @internal */ interface SavedTextureTransform { name: string; offsetX: number; offsetY: number; repeatX: number; repeatY: number; } /** * Symbol used to store saved texture transforms on the material object * @internal */ const $savedTextureTransforms = Symbol("savedTextureTransforms"); /** * Type for Three.js object render callbacks * @internal */ type ObjectRenderCallback = (this: Object3D, renderer: WebGLRenderer, scene: Scene, camera: Camera, geometry: BufferGeometry, material: Material, group: Group) => void; /** * Collect all materials from an object and its children * @internal */ function collectMaterials(object: Object3D, materials: Set<Material>): void { const obj = object as Object3D & { material?: Material | Material[] }; if (obj.material) { if (Array.isArray(obj.material)) { obj.material.forEach(mat => materials.add(mat)); } else { materials.add(obj.material); } } // For Groups, collect materials from children too if (object.type === "Group") { object.children.forEach(child => collectMaterials(child, materials)); } } /** * Find property block by checking this object and parent (if parent is a Group). * Returns both the block and the owner object. * * @param obj The object to search from * @returns The property block and its owner object, or undefined if not found * @internal */ function findPropertyBlockAndOwner(obj: Object3D): { block: MaterialPropertyBlock; owner: Object3D } | undefined { // First check if this object itself has a property block let block = registry.getBlock(obj); if (block) return { block, owner: obj }; // If not, check if parent is a Group and has a property block if (obj.parent && obj.parent.type === "Group") { block = registry.getBlock(obj.parent); if (block) return { block, owner: obj.parent }; } return undefined; } /** * Symbol to track which materials are currently being rendered for an object * @internal */ const currentlyRenderingFlag = Symbol("beforeRenderingFlag"); /** * Tracks original transparent values that were changed during render list building * @internal */ const beforeRenderListTransparentChanged = new WeakMap<Object3D, boolean>(); /** * Tracks original transmission values that were changed during render list building * @internal */ const beforeRenderListTransparentChangedTransmission = new WeakMap<Object3D, number>(); /** * Callback invoked before an object is added to the render list. * Used to temporarily override transparency/transmission for correct render list assignment. * @internal */ const onBeforeRenderListPush = function (this: Object3D, _object: Object3D, _geometry: BufferGeometry, material: Material, _group: Group) { const block = registry.getBlock(_object); if (!block) { return; } if (block.hasOverrides()) { const transmission = block.getOverride<number>("transmission")?.value; const transparent = block.getOverride("transparent")?.value; if (transmission !== undefined && typeof transmission === "number" && "transmission" in material && transmission !== material.transmission) { beforeRenderListTransparentChangedTransmission.set(this, material.transmission as number); material.transmission = transmission; } if (transparent !== undefined && typeof transparent === "boolean" && transparent !== material.transparent) { beforeRenderListTransparentChanged.set(this, material.transparent); material.transparent = transparent; } } } /** * Callback invoked after an object is added to the render list. * Restores the original transparency/transmission values that were overridden in onBeforeRenderListPush. * @internal */ const onAfterRenderListPush = function (this: Object3D, _object: Object3D, _geometry: BufferGeometry, material: Material, _group: Group) { const prevTransparent = beforeRenderListTransparentChanged.get(_object); if (prevTransparent !== undefined) { beforeRenderListTransparentChanged.delete(_object); material.transparent = prevTransparent; } const prevTransmission = beforeRenderListTransparentChangedTransmission.get(_object); if (prevTransmission !== undefined) { beforeRenderListTransparentChangedTransmission.delete(_object); (material as any).transmission = prevTransmission; } } // #region OnBeforeRender /** * Main callback invoked before rendering an object. * Applies property block overrides and defines to the material. * @internal */ const onBeforeRender_MaterialBlock: ObjectRenderCallback = function (this: Object3D, _renderer: WebGLRenderer, _scene: Scene, _camera: Camera, _geometry: BufferGeometry, material: Material, _group: Group) { // Only run if the material belongs to this object and is a "regular" material (not depth or other override material) const materials = (this as any).material as Array<Material> | Material | undefined; if (!materials) return; if (Array.isArray(materials)) { if (!materials.includes(material)) return; } else if (materials !== material) { return; } // Keep track of which materials rendering started for so we can check in onAfterRender if it was processed // (in case of override materials like depth material where onBeforeRender runs but we don't want to apply overrides) if (this[currentlyRenderingFlag] === undefined) this[currentlyRenderingFlag] = new WeakSet<Material>(); this[currentlyRenderingFlag].add(material); // Before rendering, check if this object (or its parent Group) has a property block with overrides for this material. const result = findPropertyBlockAndOwner(this); if (!result) { return; } const { block: propertyBlock, owner } = result; // Only apply if this material was registered with this property block // if (!registry.hasMaterial(owner, material)) { // return; // } const overrides = propertyBlock.overrides; const mat = material as any; // Apply defines to material - this affects shader compilation const defines = propertyBlock.getDefines(); const defineKeys = Object.keys(defines); if (defineKeys.length > 0) { if (!mat.defines) mat.defines = {}; for (const key of defineKeys) { const value = defines[key]; if (value !== undefined) { mat.defines[key] = value; } } } // Still set up cache key even if no overrides (defines affect it) if (overrides.length === 0 && defineKeys.length === 0) { return; } // Defines always affect shader compilation → need program change let needsProgramChange = defineKeys.length > 0; if (!mat[$originalValues]) { mat[$originalValues] = []; } const originalValues = mat[$originalValues] as OriginalValue[]; for (const override of overrides) { if (override.value === null) continue; const currentValue = mat[override.name]; const existingOriginal = originalValues.find((o: OriginalValue) => o.name === override.name); if (existingOriginal) { // Update to current value each frame so animations/external changes are preserved existingOriginal.value = currentValue; } else { originalValues.push({ name: override.name, value: currentValue }); } // Check if this override changes shader features (truthiness change). // E.g. null → Texture enables USE_LIGHTMAP, Texture → null disables it. // Pure uniform changes (red → blue, textureA → textureB) don't need program switch. if (!needsProgramChange && !!currentValue !== !!override.value) { needsProgramChange = true; } // Set all material properties including lightMap - // three.js reads material.lightMap to determine shader parameters and upload uniforms mat[override.name] = override.value; // Apply per-object texture transform (offset/repeat) if specified if (override.textureTransform && override.value instanceof Texture) { const tex = override.value; if (!mat[$savedTextureTransforms]) mat[$savedTextureTransforms] = []; (mat[$savedTextureTransforms] as SavedTextureTransform[]).push({ name: override.name, offsetX: tex.offset.x, offsetY: tex.offset.y, repeatX: tex.repeat.x, repeatY: tex.repeat.y }); const t = override.textureTransform; if (t.offset) tex.offset.copy(t.offset); if (t.repeat) tex.repeat.copy(t.repeat); } } // Only set needsUpdate when overrides change shader features (truthiness changes // like null↔texture, or defines added). This triggers getProgram() for program switches. // Pure uniform overrides (color, roughness) skip this — no version increment needed. if (needsProgramChange) { mat.needsUpdate = true; } // _forceRefresh triggers uniform re-upload for consecutive objects sharing // the same program and material (without it three.js skips the upload). mat._forceRefresh = true; }; // #region OnAfterRender /** * Main callback invoked after rendering an object. * Restores the original material property values and defines. * @internal */ const onAfterRender_MaterialBlock: ObjectRenderCallback = function (this: Object3D, _renderer: WebGLRenderer, _scene: Scene, _camera: Camera, _geometry: BufferGeometry, material: Material, _group: Group) { // We don't want to run this logic if onBeforeRender didn't run for this material (e.g. due to DepthMaterial or other override material), so we check the flag set in onBeforeRender if (this[currentlyRenderingFlag] === undefined) return; if (!this[currentlyRenderingFlag].has(material)) return; this[currentlyRenderingFlag].delete(material); const result = findPropertyBlockAndOwner(this); if (!result) { return; } const { block: propertyBlock, owner } = result; // Only restore if this material was registered with this property block // if (!registry.hasMaterial(owner, material)) { // return; // } const overrides = propertyBlock.overrides; const mat = material as any; const originalValues = mat[$originalValues] as OriginalValue[] | undefined; // Clean up defines — this affects shader compilation const defines = propertyBlock.getDefines(); const defineKeys = Object.keys(defines); let needsProgramChange = false; if (defineKeys.length > 0 && mat.defines) { for (const key of defineKeys) { delete mat.defines[key]; } needsProgramChange = true; } if (overrides.length === 0) { if (needsProgramChange) { mat.needsUpdate = true; mat._forceRefresh = true; } return; } if (!originalValues) return; // Restore texture transforms before restoring material properties const savedTransforms = mat[$savedTextureTransforms] as SavedTextureTransform[] | undefined; if (savedTransforms && savedTransforms.length > 0) { for (const saved of savedTransforms) { const override = overrides.find(o => o.name === saved.name); if (override?.value instanceof Texture) { override.value.offset.set(saved.offsetX, saved.offsetY); override.value.repeat.set(saved.repeatX, saved.repeatY); } } savedTransforms.length = 0; } for (const override of overrides) { const original = originalValues.find(o => o.name === override.name); if (original) { // Check if restoring changes shader features (truthiness change) if (!needsProgramChange && !!override.value !== !!original.value) { needsProgramChange = true; } mat[override.name] = original.value; } } // Only set needsUpdate when restoring affects shader features if (needsProgramChange) { mat.needsUpdate = true; } // Always force uniform refresh so the next object gets correct values mat._forceRefresh = true; }; // #region Attach Callbacks /** * Attaches the property block render callbacks to an object and its child meshes. * @param object The object to attach callbacks to * @param _propertyBlock The property block being attached (unused but kept for clarity) * @internal */ function attachPropertyBlockToObject(object: Object3D, _propertyBlock: MaterialPropertyBlock): void { // Collect and register all materials that belong to this property block // const materials = new Set<Material>(); // collectMaterials(object, materials); // materials.forEach(mat => registry.addMaterial(object, mat)); // Attach callbacks to renderable objects (Mesh, SkinnedMesh) // Groups don't render themselves but we still need to handle child meshes if (object.type === "Group") { object.children.forEach(child => { if (child.type === "Mesh" || child.type === "SkinnedMesh") { attachCallbacksToMesh(child, object, _propertyBlock); } }); } else if (object.type === "Mesh" || object.type === "SkinnedMesh") { attachCallbacksToMesh(object, object, _propertyBlock); } } /** * Attaches render callbacks to a specific mesh object. * Chains with existing callbacks if they exist. * @param mesh The mesh to attach callbacks to * @param propertyBlockOwner The object that owns the property block (may be the mesh itself or its parent Group) * @internal */ function attachCallbacksToMesh(mesh: Object3D, propertyBlockOwner: Object3D, _propertyBlock: MaterialPropertyBlock): void { // Check if this specific mesh already has our callbacks attached for this property block owner if (registry.isHooked(mesh, propertyBlockOwner)) { // Already hooked for this property block owner return; } registry.addHook(mesh, propertyBlockOwner); /** * Expose the property block for e.g. Needle Inspector */ mesh["needle:materialPropertyBlock"] = _propertyBlock; if (!mesh.onBeforeRender) { mesh.onBeforeRender = onBeforeRender_MaterialBlock; } else { const original = mesh.onBeforeRender; mesh.onBeforeRender = function (renderer, scene, camera, geometry, material, group) { original.call(this, renderer, scene, camera, geometry, material, group); onBeforeRender_MaterialBlock.call(this, renderer, scene, camera, geometry, material, group); }; } if (!mesh.onAfterRender) { mesh.onAfterRender = onAfterRender_MaterialBlock; } else { const original = mesh.onAfterRender; mesh.onAfterRender = function (renderer, scene, camera, geometry, material, group) { onAfterRender_MaterialBlock.call(this, renderer, scene, camera, geometry, material, group); original.call(this, renderer, scene, camera, geometry, material, group); }; } /** @ts-ignore patched in three.js */ mesh.onBeforeRenderListPush = onBeforeRenderListPush; /** @ts-ignore patched in three.js */ mesh.onAfterRenderListPush = onAfterRenderListPush; } //#endregion /** * Checks if an object has a MaterialPropertyBlock attached to it. * * @param object The object to check * @returns True if the object has a property block registered * * @example * ```typescript * if (objectHasPropertyBlock(myMesh)) { * console.log("This mesh has property overrides"); * } * ``` */ export function objectHasPropertyBlock(object: Object3D): boolean { return registry.getBlock(object) !== undefined; }