@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
JavaScript
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