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.

772 lines 29.4 kB
import { Texture } from "three"; /** * 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 objectToBlock = new WeakMap(); // 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 meshToOwners = new WeakMap(); // Track original callback functions for cleanup (reserved for future use) meshToOriginalCallbacks = new WeakMap(); getBlock(object) { return this.objectToBlock.get(object); } setBlock(object, block) { this.objectToBlock.set(object, block); } deleteBlock(object) { 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, owner) { return this.meshToOwners.get(mesh)?.has(owner) ?? false; } addHook(mesh, owner) { let owners = this.meshToOwners.get(mesh); if (!owners) { owners = new Set(); this.meshToOwners.set(mesh, owners); } owners.add(owner); } removeHook(mesh, owner) { const owners = this.meshToOwners.get(mesh); if (owners) { owners.delete(owner); if (owners.size === 0) { this.meshToOwners.delete(mesh); } } } getOriginalCallbacks(mesh) { return this.meshToOriginalCallbacks.get(mesh); } setOriginalCallbacks(mesh, callbacks) { 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 { _overrides = []; _defines = {}; _object = null; /** The object this property block is attached to */ get object() { return this._object; } /** * Creates a new MaterialPropertyBlock * @param object The object this property block is for (optional) */ constructor(object = 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(object) { let block = registry.getBlock(object); if (!block) { block = new MaterialPropertyBlock(object); registry.setBlock(object, block); attachPropertyBlockToObject(object, block); } return block; } /** * 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) { 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; } setOverride(name, value, textureTransform) { const existing = this._overrides.find(o => o.name === name); if (existing) { existing.value = value; existing.textureTransform = textureTransform; } else { this._overrides.push({ name, value, textureTransform }); } } getOverride(name) { 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(name) { 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() { this._overrides = []; } /** * Gets all property overrides as a readonly array * @returns Array of all property overrides */ get overrides() { return this._overrides; } /** * Checks if this property block has any overrides * @returns True if there are any overrides set */ hasOverrides() { 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, value) { this._defines[name] = value; } /** * Remove a shader define * @param name The define name to remove */ clearDefine(name) { this._defines[name] = undefined; } /** * Get all defines set on this property block * @returns A readonly record of all defines */ getDefines() { 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() { const parts = []; // 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.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; 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; 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"); /** * Symbol used to store saved texture transforms on the material object * @internal */ const $savedTextureTransforms = Symbol("savedTextureTransforms"); /** * Collect all materials from an object and its children * @internal */ function collectMaterials(object, materials) { const obj = object; 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) { // 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(); /** * Tracks original transmission values that were changed during render list building * @internal */ const beforeRenderListTransparentChangedTransmission = new WeakMap(); /** * 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 (_object, _geometry, material, _group) { const block = registry.getBlock(_object); if (!block) { return; } if (block.hasOverrides()) { const transmission = block.getOverride("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); 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 (_object, _geometry, material, _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.transmission = prevTransmission; } }; // #region OnBeforeRender /** * Main callback invoked before rendering an object. * Applies property block overrides and defines to the material. * @internal */ const onBeforeRender_MaterialBlock = function (_renderer, _scene, _camera, _geometry, material, _group) { // Only run if the material belongs to this object and is a "regular" material (not depth or other override material) const materials = this.material; 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(); 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; // 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]; for (const override of overrides) { if (override.value === null) continue; const currentValue = mat[override.name]; const existingOriginal = originalValues.find((o) => 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].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 = function (_renderer, _scene, _camera, _geometry, material, _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; const originalValues = mat[$originalValues]; // 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]; 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, _propertyBlock) { // 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, propertyBlockOwner, _propertyBlock) { // 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) { return registry.getBlock(object) !== undefined; } //# sourceMappingURL=engine_materialpropertyblock.js.map