@interverse/three-layered-material
v1.0.3
Published
Modular PBR material layering system for Three.js with TSL node-based blending
Maintainers
Readme
@interverse/three-layered-material
A procedural, multi-layer physically based material system built using Three.js TSL and MeshPhysicalNodeMaterial that brings Substance Painter-style layering into Three.js runtime.
📦 Installation
npm install @interverse/three-layered-material
# or
yarn add @interverse/three-layered-materialPeer Dependencies:
three>= 0.182.0
🎯 What It Is
Think of this as Substance Painter + Gaea terrain shader → inside Three.js. Each layer is a complete material slice that gets blended together on the GPU with advanced masking, blending, and procedural effects.
Core Philosophy
- No shader coding required - just configure layers via JavaScript
- Runtime procedural authoring - modify materials without recompiling shaders
- Production-ready features - all the tools you need for complex materials
🚀 Quick Start
import { LayeredMaterial } from '@interverse/three-layered-material';
import * as THREE from 'three';
const loader = new THREE.TextureLoader();
const material = new LayeredMaterial({
layers: [
{
name: 'Base Metal',
map: {
color: loader.load('metal_color.jpg'),
normal: loader.load('metal_normal.jpg'),
roughness: loader.load('metal_roughness.jpg'),
},
roughness: 0.3,
metalness: 0.8,
},
{
name: 'Rust Overlay',
map: {
color: loader.load('rust_color.jpg'),
normal: loader.load('rust_normal.jpg'),
},
mask: {
map: loader.load('rust_mask.png'),
channel: 'r',
useNoise: true,
},
blendMode: {
color: 'overlay',
normal: 'rnb',
}
}
]
});
// Apply to any mesh
const mesh = new THREE.Mesh(geometry, material);🎨 Layer Configuration
Each layer supports these properties:
Texture Maps
map: {
color?: THREE.Texture, // Base color
normal?: THREE.Texture, // Normal map
roughness?: THREE.Texture, // Roughness
metalness?: THREE.Texture, // Metalness
ao?: THREE.Texture, // Ambient Occlusion
height?: THREE.Texture, // Height map for parallax/blending
arm?: THREE.Texture, // Packed ARM (AO/Rough/Metal in RGB)
}Material Properties
scale?: number; // Texture tiling scale
roughness?: number; // Fallback roughness (0-1)
metalness?: number; // Fallback metalness (0-1)
colorTint?: { // Color multiplier (NEW!)
r: number; // Red channel (0-1, default 1)
g: number; // Green channel (0-1, default 1)
b: number; // Blue channel (0-1, default 1)
};🔌 Using Materials as Layer Inputs
One of the most powerful features is the ability to use existing Three.js materials as layer inputs. This allows you to:
- Reuse pre-built materials from your library
- Combine node materials with layered blending
- Apply masking and features to existing materials
Basic Material Input
import { LayeredMaterial } from '@interverse/three-layered-material';
import { MeshPhysicalNodeMaterial } from 'three/webgpu';
// Create or import an existing node material
const existingMaterial = new MeshPhysicalNodeMaterial({
color: 0x4488ff,
roughness: 0.3,
metalness: 0.8
});
// Use it as a layer
const material = new LayeredMaterial({
layers: [
{
name: 'Base',
map: { color: baseTexture }
},
{
name: 'Overlay Material',
materialInput: existingMaterial, // Use material as layer!
mask: {
useSlope: true,
slopeMin: 0.3,
slopeMax: 0.7
}
}
]
});Material Transform Options
Control how the material is processed:
{
materialInput: myNodeMaterial,
materialTransform: {
extractTextures: true, // Extract textures for triplanar/bombing
overrideScale: true, // Apply layer scale to material
respectMaterialSettings: true // Keep material's original settings
},
// Features still work with material inputs!
edgeWear: { enable: true, intensity: 1.5 },
triplanar: { enable: true }
}Layer Input Priority
The system processes layer inputs in this order:
materialInput- MeshPhysicalNodeMaterial (highest priority)map- Texture maps (color, normal, roughness, etc.)color- Solid color fallback (lowest priority)
Compatible Materials
Any MeshPhysicalNodeMaterial can be used, including those with:
- Custom shader nodes (
colorNode,normalNode, etc.) - Standard texture maps
- Subsurface scattering, transmission, clearcoat
// Example: Subsurface material as a layer
const skinMaterial = new MeshPhysicalNodeMaterial({
color: 0xffccaa,
subsurfaceColor: 0xff6666,
subsurface: 0.5
});
const characterMaterial = new LayeredMaterial({
layers: [
{ materialInput: skinMaterial },
{
name: 'Makeup',
map: { color: makeupTexture },
mask: { map: makeupMaskTexture },
blendMode: { color: 'overlay' }
}
]
});🎭 Advanced Features
1. Edge Wear System
Realistic wear and tear on edges and corners:
edgeWear: {
enable: true,
intensity: 1.5, // How much wear to apply
threshold: 0.1, // Curvature threshold for wear
falloff: 0.3, // Smooth falloff range
sharpness: 2.0, // Non-linear shaping power
color: {r: 0.8, g: 0.7, b: 0.6}, // Exposed material color
affectsMaterial: true, // Also change roughness/metalness
roughness: 0.2, // Roughness at worn edges
metalness: 1.0, // Metalness at worn edges
wearPattern: 'curvature', // 'curvature'|'ambient_occlusion'|'world_space'|'combined'
curvatureMethod: 'normal', // 'normal'|'position'|'simplified'|'world'|'laplace'
useNoise: false, // Add noise variation to wear
}Wear Patterns
| Pattern | Description | Best For |
|---------|-------------|----------|
| curvature | Detects edges via surface curvature | Metal edges, corners |
| ambient_occlusion | Uses surface orientation (upward-facing) | Sheltered areas |
| world_space | Combines wind direction + height + orientation | Environmental weathering |
| combined | All patterns weighted together | Most realistic results |
Curvature Methods Performance
| Method | Performance | Quality | Description |
|--------|-------------|---------|-------------|
| normal | ⚡ Fast | ★★★★ | Normal derivative (default, best balance) |
| simplified | ⚡⚡ Fastest | ★★★ | Z-component only, very cheap |
| world | ⚡ Fast | ★★★★ | World normal derivative |
| position | ⚡ Medium | ★★★★★ | Second derivative of position |
| laplace | 🐢 Slow | ★★★★★ | Most accurate, computationally expensive |
Recommendations:
- Use
normal(default) for most cases - Use
simplifiedfor mobile/low-end GPUs - Use
laplaceonly when maximum accuracy is needed
2. Texture Bombing
Eliminate tiling artifacts with stochastic sampling:
textureBombing: {
enable: true,
blend: 0.6, // Blend factor between samples (0-1)
}3. Triplanar Mapping
Seamless projection on complex geometry:
triplanar: {
enable: true,
useWorldPosition: true, // Use world or object space
}4. Advanced Masking
Slope-Based Masking (Terrain)
mask: {
useSlope: true,
slopeMin: 0.3, // Minimum slope angle (0=flat, 1=vertical)
slopeMax: 0.7, // Maximum slope angle
}Height-Based Masking
mask: {
useHeight: true,
heightMin: 0.0, // Minimum world height
heightMax: 10.0, // Maximum world height
}Procedural Noise Masking
mask: {
useNoise: true,
noiseType: 'perlin', // 'perlin'|'voronoi'|'fbm'
noiseScale: 2.0,
noiseOctaves: 4,
noiseThreshold: 0.5, // Cutoff for binary masks
}5. Blend Modes
Control how layers interact:
blendMode: {
color: 'overlay', // 'normal'|'multiply'|'overlay'|'screen'|'add'|etc.
normal: 'rnb', // 'rnb'|'linear'|'whiteout'|'udn'|'partial_derivative'
roughness: 'max', // 'normal'|'min'|'max'|'multiply'|'average'
metalness: 'min', // 'normal'|'min'|'max'|'multiply'|'average'
ao: 'multiply', // 'normal'|'min'|'max'|'multiply'
}6. Height-Based Blending
Use height maps for realistic transitions:
heightBlend: {
enable: true,
strength: 2.0, // How much height affects blending
sharpness: 4.0, // Sharpness of the blend edge
}7. Parallax Occlusion Mapping
Create depth effects without geometry displacement:
parallax: {
enable: true,
method: 'pom', // 'simple' | 'steep' | 'pom' (best quality)
scale: 0.1, // Parallax depth strength
steps: 16, // Ray marching steps (higher = better quality)
quality: 'high', // 'low' | 'medium' | 'high'
}Methods:
simple- Fast single offset, good for subtle effectssteep- Fixed-step ray marching, good balancepom- Full Parallax Occlusion Mapping with interpolation (best quality)
Note: Requires geometry with tangent attributes. Use
geometry.computeTangents()if you see warnings.
🎪 Real-World Examples
Terrain Material
const terrainMaterial = new LayeredMaterial({
layers: [
{
name: 'Grass Base',
map: { color: grassColor, normal: grassNormal, roughness: grassRoughness },
triplanar: { enable: true },
textureBombing: { enable: true, blend: 0.6 },
scale: 0.5,
},
{
name: 'Cliff Rock',
map: { color: rockColor, normal: rockNormal, height: rockHeight },
mask: {
useSlope: true,
slopeMin: 0.4,
slopeMax: 0.8,
useNoise: true,
noiseType: 'voronoi',
},
heightBlend: { enable: true, strength: 3.0 },
blendMode: { normal: 'rnb' },
},
{
name: 'Sand Path',
map: { color: sandColor, normal: sandNormal },
mask: {
map: pathMask,
channel: 'r',
useNoise: true,
},
edgeWear: {
enable: true,
intensity: 1.2,
wearPattern: 'world_space',
},
}
]
});Painted Metal with Wear
const metalMaterial = new LayeredMaterial({
layers: [
{
name: 'Base Metal',
map: { color: metalColor, normal: metalNormal, roughness: metalRoughness },
roughness: 0.1,
metalness: 0.9,
},
{
name: 'Paint Layer',
map: { color: paintColor, normal: paintNormal, roughness: paintRoughness },
mask: { map: paintMask, channel: 'r' },
edgeWear: {
enable: true,
intensity: 2.0,
color: {r: 0.9, g: 0.8, b: 0.7}, // Worn metal color
affectsMaterial: true,
roughness: 0.3,
metalness: 0.95,
wearPattern: 'combined',
},
blendMode: {
color: 'normal',
normal: 'whiteout', // Sharp edges for paint chips
},
}
]
});🔧 Runtime API
Layer Management
// Add new layer at the top
material.addLayer({
name: 'New Dirt Layer',
map: { color: dirtColor, normal: dirtNormal },
// ... config
});
// Insert layer at specific position (NEW!)
material.insertLayer(1, {
name: 'Middle Layer',
map: { color: middleTexture },
});
// Move layer from one position to another (NEW!)
material.moveLayer(0, 2); // Move layer 0 to position 2
// Swap two layers (NEW!)
material.swapLayers(0, 1); // Swap layer 0 and 1
// Get layer count (NEW!)
const count = material.getLayerCount();
// Get layer by index (NEW!)
const layer = material.getLayer(0);
// Update existing layer
material.updateLayer(1, {
scale: 2.0,
roughness: 0.8,
colorTint: { r: 1.0, g: 0.9, b: 0.8 }, // Warm tint
});
// Remove layer
material.removeLayer(0);Dynamic Modifications
All changes automatically trigger shader recompilation:
// Enable/disable features at runtime
material.updateLayer(0, {
edgeWear: { enable: true, intensity: 1.5 },
textureBombing: { enable: false },
});
// Change blend modes dynamically
material.updateLayer(1, {
blendMode: { color: 'multiply', normal: 'linear' }
});
// Apply color tint (NEW!)
material.updateLayer(0, {
colorTint: { r: 0.8, g: 0.8, b: 1.0 } // Cool blue tint
});Uniform-Based Transitions (Performance Optimized)
For real-time transitions without shader rebuilds, use UniformDynamicMaterial:
import { UniformDynamicMaterial } from '@interverse/three-layered-material';
// Create material with uniform-based properties
const material = new UniformDynamicMaterial({
layers: [
{
name: 'Ground',
map: { color: groundTexture },
scale: 1.0,
roughness: 0.8
}
]
});
// Define target state
const wetLayers = [
{
name: 'Wet Ground',
map: { color: wetTexture },
scale: 0.8,
roughness: 0.3 // Wet surfaces are smoother
}
];
// Animate transition at 60fps WITHOUT shader rebuilds!
function animate(deltaTime) {
const progress = /* your animation logic */;
// This only updates uniform values - no shader recompilation
material.setTransitionFast(wetLayers, progress);
}
// Complete transition when done
material.completeTransition();Performance Comparison:
| Method | Shader Rebuild | Frame Impact |
|--------|----------------|--------------|
| DynamicLayeredMaterial.setTransition() | ❌ Every frame | 50-200ms stutter |
| UniformDynamicMaterial.setTransitionFast() | ✅ None | <1ms (GPU uniform update) |
Use
UniformDynamicMaterialfor: Day/night cycles, weather transitions, real-time sliders, any smooth animation.
🎯 Use Cases
Perfect For:
- Terrain systems - Grass, rock, sand, snow layers with slope-based masking
- Weathered props - Paint wear, rust, dirt accumulation
- Architectural materials - Plaster, brick, concrete with edge wear
- Vehicle materials - Paint, dirt, scratches, exposed metal
- Character materials - Skin, fabric, armor with layered details
Solves These Problems:
- ✅ Tiling artifacts - Texture bombing breaks up repetition
- ✅ Seamless complex geometry - Triplanar mapping
- ✅ Realistic material transitions - Height-based blending
- ✅ Procedural wear and tear - Curvature-based edge detection
- ✅ Runtime material authoring - No shader compilation needed
🧠 Mental Model
Think of the pipeline as:
Layer 1 → Sample textures → Apply edge wear → Output LayerData
Layer 2 → Sample textures → Apply edge wear → Output LayerData
...
Blender:
Base = Layer 1
For each additional layer:
Mask = Calculate mask (texture + slope + height + noise)
Blend = Height-based blend between base and layer using mask
Base = Result
Return final blended material🔮 What's Next?
The system is ready for:
- GUI Inspector (dat.GUI or Leva integration)
- Debug Visualizer - View masks, curvature, wear patterns in real-time
- Material Presets - Common configurations (metal, terrain, fabric)
- Export System - Bake final materials to textures
💡 Pro Tips
- Start Simple - Begin with 2-3 layers and add complexity gradually
- Use ARM Maps - Pack AO/Roughness/Metalness for better performance
- Height Maps Matter - Essential for realistic blending between layers
- Noise is Your Friend - Add organic variation to break up patterns
- Test Different Blend Modes - Each material interaction benefits from different blending approaches
This system gives you the power of offline material authoring tools with the flexibility of real-time procedural generation. No shader expertise required - just creative layer configuration!
🏔️ Terrain Integration
Use layered materials with terrain systems that require vertex displacement:
LayeredTerrainMaterialProvider
Compatible with @interverse/three-terrain-lod:
import { LayeredTerrainMaterialProvider } from '@interverse/three-layered-material';
import { TerrainLOD } from '@interverse/three-terrain-lod';
// Create layered terrain material
const terrainMaterial = new LayeredTerrainMaterialProvider({
layers: [
{
name: 'Grass',
map: {
color: grassTexture,
normal: grassNormal,
height: grassHeight // Used for height blending
},
scale: 0.5
},
{
name: 'Rock',
map: {
color: rockTexture,
normal: rockNormal,
height: rockHeight
},
mask: {
useSlope: true,
slopeMin: 0.4,
slopeMax: 0.8
},
heightBlend: { enable: true, strength: 2.0 }
},
{
name: 'Snow',
map: { color: snowTexture },
mask: {
useHeight: true,
heightMin: 50,
heightMax: 100
}
}
],
// Terrain-specific options
useLayerHeightBlending: true, // Blend layer heights into displacement
layerHeightInfluence: 0.3, // How much layer heights affect terrain
displacementScale: 1.0 // Global displacement multiplier
});
// Apply to terrain
const terrain = new TerrainLOD({
heightMapUrl: '/terrain/heightmap.png',
worldSize: 1000,
maxHeight: 100
});
terrain.setMaterialProvider(terrainMaterial);Features
- Slope-based layer masking - Grass on flat, rock on slopes
- Height-based layer masking - Snow above certain altitude
- Layer height blending - Mix layer heights into displacement for detail
- Full layered material features - Edge wear, texture bombing, parallax, etc.
MaterialVariant Class Documentation
Overview
The MaterialVariant class extends LayeredMaterial to create material variations that automatically inherit and sync with a base material while applying custom overrides. Think of it as "smart material inheritance" - when the base material changes, all variants automatically update while maintaining their unique customizations.
Key Features
- 🔄 Automatic Synchronization: Variants update when base material changes
- 🎨 Layer Overrides: Customize specific properties while inheriting others
- ⚡ Performance Optimized: Two implementation strategies available
- 🔧 Runtime Flexibility: Update overrides dynamically
- 🎯 Non-Destructive: Base material remains unchanged
Installation & Import
import {
LayeredMaterial,
MaterialVariant,
ObservableLayeredMaterial,
ObservableMaterialVariant
} from '@interverse/three-layered-material';Basic Usage
Creating a Simple Variant
// Create base material
const baseMaterial = new LayeredMaterial({
layers: [
{
name: 'Ground',
map: { color: groundTexture, normal: groundNormal },
scale: 1.0,
roughness: 0.8,
metalness: 0.0
},
{
name: 'Grass',
map: { color: grassTexture, normal: grassNormal },
scale: 0.5,
roughness: 0.9,
metalness: 0.0
}
]
});
// Create variant with overrides
const dryGroundVariant = new MaterialVariant(baseMaterial, [
{
// Override ground layer
scale: 2.0, // Double the scale
roughness: 0.95, // Make drier/more rough
metalness: 0.1 // Slight metallic sheen
},
{
// Override grass layer
scale: 0.3, // Smaller grass pattern
roughness: 0.7 // Less rough grass
}
]);
// Apply to mesh
const terrainMesh = new THREE.Mesh(geometry, dryGroundVariant);Observable Variant (Recommended)
// Use ObservableLayeredMaterial for better performance
const observableBase = new ObservableLayeredMaterial({
layers: [/* your layers */]
});
const observableVariant = new ObservableMaterialVariant(observableBase, [
{ scale: 2.0, roughness: 0.9 },
{ metalness: 0.2 }
]);API Reference
Constructor
new MaterialVariant(baseMaterial: LayeredMaterial, overrides: Partial<LayerConfig>[])Parameters:
baseMaterial: The sourceLayeredMaterialto inherit fromoverrides: Array of partial layer configurations (one per base layer)
Example:
const variant = new MaterialVariant(baseMaterial, [
{ scale: 2.0 }, // Override layer 0 scale
{ roughness: 0.5 }, // Override layer 1 roughness
{}, // No override for layer 2 (if exists)
{ metalness: 0.8 } // Override layer 3 metalness
]);Instance Methods
setOverrides(overrides: Partial<LayerConfig>[])
Update all overrides at once.
// Change all overrides
variant.setOverrides([
{ scale: 3.0, roughness: 0.3 },
{ metalness: 0.5, scale: 0.8 }
]);updateOverride(layerIndex: number, override: Partial<LayerConfig>)
Update a specific layer's override.
// Update only the ground layer
variant.updateOverride(0, {
scale: 1.5,
roughness: 0.6
});
// Add override to a new layer
variant.updateOverride(2, {
metalness: 0.3
});getOverrides(): Partial<LayerConfig>[]
Get current overrides array.
const currentOverrides = variant.getOverrides();
console.log(currentOverrides[0].scale); // 2.0getBaseMaterial(): LayeredMaterial
Get the base material instance.
const base = variant.getBaseMaterial();
base.addLayer(newLayerConfig); // Variant will auto-updateclone(): MaterialVariant
Create a deep clone of the variant.
const variantCopy = variant.clone();dispose(): void
Clean up resources and listeners.
variant.dispose();Advanced Usage Patterns
1. Material Presets System
// Define material presets
const MaterialPresets = {
DRY: [
{ scale: 2.0, roughness: 0.95 },
{ scale: 0.3, roughness: 0.7 }
],
WET: [
{ scale: 0.8, roughness: 0.3 },
{ scale: 0.6, roughness: 0.2 }
],
SNOWY: [
{ scale: 1.2, roughness: 0.4 },
{ scale: 0.4, roughness: 0.3 }
]
};
// Create variant and apply preset
const weatherVariant = new MaterialVariant(baseMaterial, MaterialPresets.DRY);
// Change weather dynamically
function setWeather(weatherType: 'DRY' | 'WET' | 'SNOWY') {
weatherVariant.setOverrides(MaterialPresets[weatherType]);
}2. Progressive Material Wear
class WearableMaterialVariant extends MaterialVariant {
private wearLevel: number = 0;
setWear(level: number) {
this.wearLevel = level;
// Increase roughness and reduce scale with wear
this.setOverrides([
{
scale: 1.0 + level * 0.5, // Pattern stretches with wear
roughness: 0.8 + level * 0.2, // Gets rougher
metalness: level * 0.5 // Metal shows through
},
{
scale: 0.5 - level * 0.2, // Grass thins out
roughness: 0.9 + level * 0.1
}
]);
}
}3. Seasonal Material Transitions
class SeasonalMaterialVariant extends MaterialVariant {
private seasonProgress: number = 0; // 0 = spring, 0.25 = summer, etc.
updateSeason(progress: number) {
this.seasonProgress = progress;
// Spring to Summer transition
const springToSummer = this.interpolateOverrides(
[
{ scale: 1.0, roughness: 0.8 }, // Spring
{ scale: 0.5, roughness: 0.6 } // Summer grass
],
[
{ scale: 0.8, roughness: 0.7 }, // Summer
{ scale: 0.3, roughness: 0.9 } // Dry summer grass
],
progress * 4 // Scale to spring-summer range
);
this.setOverrides(springToSummer);
}
private interpolateOverrides(
start: Partial<LayerConfig>[],
end: Partial<LayerConfig>[],
factor: number
): Partial<LayerConfig>[] {
// Implementation for smooth interpolation between override sets
return start.map((startOverride, i) => ({
...this.lerpOverride(startOverride, end[i] || {}, factor)
}));
}
}4. Multi-Variant Management
class MaterialVariantManager {
private variants = new Map<string, MaterialVariant>();
private baseMaterial: LayeredMaterial;
constructor(baseMaterial: LayeredMaterial) {
this.baseMaterial = baseMaterial;
}
createVariant(name: string, overrides: Partial<LayerConfig>[]): MaterialVariant {
const variant = new MaterialVariant(this.baseMaterial, overrides);
this.variants.set(name, variant);
return variant;
}
getVariant(name: string): MaterialVariant | undefined {
return this.variants.get(name);
}
updateAllVariants(): void {
// Force update all variants (useful after base material changes)
this.variants.forEach(variant => {
variant.setOverrides(variant.getOverrides());
});
}
}
// Usage
const manager = new MaterialVariantManager(baseMaterial);
manager.createVariant('dry', [{ scale: 2.0, roughness: 0.9 }]);
manager.createVariant('wet', [{ scale: 0.8, roughness: 0.3 }]);
const dryVariant = manager.getVariant('dry');Real-World Examples
1. Terrain Biome Variants
// Base terrain material
const baseTerrain = new LayeredMaterial({
layers: [
{ name: 'Soil', map: { color: soilTexture }, scale: 1.0 },
{ name: 'Grass', map: { color: grassTexture }, scale: 0.5 },
{ name: 'Rock', map: { color: rockTexture }, scale: 0.3 }
]
});
// Biome variants
const forestVariant = new MaterialVariant(baseTerrain, [
{ scale: 1.2 }, // Rich soil
{ scale: 0.8 }, // Dense grass
{ scale: 0.1 } // Few rocks
]);
const desertVariant = new MaterialVariant(baseTerrain, [
{ scale: 0.6 }, // Sandy soil
{ scale: 0.1 }, // Sparse grass
{ scale: 0.8 } // Many rocks
]);
const arcticVariant = new MaterialVariant(baseTerrain, [
{ scale: 0.3 }, // Frozen ground
{ scale: 0.0 }, // No grass
{ scale: 0.5 } // Ice-covered rocks
]);2. Building Material Aging
// Base building material
const newBuilding = new LayeredMaterial({
layers: [
{ name: 'Paint', map: { color: freshPaint }, roughness: 0.3 },
{ name: 'Concrete', map: { color: cleanConcrete }, roughness: 0.5 }
]
});
// Age variants
const aged5Years = new MaterialVariant(newBuilding, [
{ roughness: 0.5 }, // Paint faded
{ roughness: 0.6 } // Concrete weathered
]);
const aged20Years = new MaterialVariant(newBuilding, [
{ roughness: 0.8 }, // Paint heavily worn
{ roughness: 0.9 } // Concrete eroded
]);3. Character Material Variants
// Base character material
const baseCharacter = new LayeredMaterial({
layers: [
{ name: 'Skin', map: { color: baseSkin }, roughness: 0.4 },
{ name: 'Clothing', map: { color: baseClothing }, roughness: 0.6 }
]
});
// Character state variants
const healthyVariant = new MaterialVariant(baseCharacter, [
{ /* Default skin */ },
{ /* Default clothing */ }
]);
const injuredVariant = new MaterialVariant(baseCharacter, [
{
map: { color: paleSkinTexture }, // Pale when injured
roughness: 0.6 // Sweaty skin
},
{
map: { color: bloodStainedTexture }, // Blood on clothing
roughness: 0.8 // Rough from damage
}
]);
const poweredUpVariant = new MaterialVariant(baseCharacter, [
{
map: { color: glowingSkinTexture }, // Glowing effect
roughness: 0.2 // Smooth, energized
}
]);Performance Considerations
1. Use Observable Variants When Possible
// ✅ Good - More efficient
const observableBase = new ObservableLayeredMaterial({ layers: [] });
const observableVariant = new ObservableMaterialVariant(observableBase, overrides);
// ⚠️ Acceptable - Less efficient but works with any LayeredMaterial
const regularVariant = new MaterialVariant(anyBaseMaterial, overrides);2. Batch Override Updates
// ✅ Good - Single update
variant.setOverrides(newOverrides);
// ❌ Avoid - Multiple updates
variant.updateOverride(0, { scale: 2.0 });
variant.updateOverride(1, { roughness: 0.5 });
variant.updateOverride(2, { metalness: 0.3 });3. Limit Variant Count
// For high-performance scenarios, consider:
class MaterialVariantPool {
private pool: MaterialVariant[] = [];
getVariant(base: LayeredMaterial, overrides: Partial<LayerConfig>[]): MaterialVariant {
let variant = this.pool.find(v =>
v.getBaseMaterial() === base &&
JSON.stringify(v.getOverrides()) === JSON.stringify(overrides)
);
if (!variant) {
variant = new MaterialVariant(base, overrides);
this.pool.push(variant);
}
return variant;
}
}Common Pitfalls
1. Circular References
// ❌ Don't create variants of variants
const variant1 = new MaterialVariant(base, overrides1);
const variant2 = new MaterialVariant(variant1, overrides2); // Avoid
// ✅ Create all variants from the same base
const variant1 = new MaterialVariant(base, overrides1);
const variant2 = new MaterialVariant(base, overrides2);2. Memory Leaks
// Always dispose variants when no longer needed
const variant = new MaterialVariant(base, overrides);
// When done with variant:
variant.dispose();3. Override Specificity
// ✅ Clear what gets overridden
const overrides = [
{ scale: 2.0, roughness: 0.8 }, // Override scale and roughness
{ metalness: 0.5 } // Override only metalness
];
// ❌ Unclear overrides
const confusingOverrides = [
{ scale: 2.0 },
{ /* Empty - but is this intentional? */ }
];Migration Tips
From Manual Material Copying
Before:
// Manual copying (error-prone)
const variantLayers = JSON.parse(JSON.stringify(baseMaterial.layers));
variantLayers[0].scale = 2.0;
const variant = new LayeredMaterial({ layers: variantLayers });
// Need to manually update when base changes? 😥After:
// Automatic synchronization
const variant = new MaterialVariant(baseMaterial, [
{ scale: 2.0 }
]);
// Automatically updates when base changes! 🎉The MaterialVariant system provides a robust way to create material variations that stay in sync with their base, reducing bugs and making your material system more maintainable and dynamic!
DynamicLayeredMaterial Class Documentation
Overview
The DynamicLayeredMaterial enables real-time material transitions between different material states. It allows you to smoothly blend from a source material configuration to a target configuration, perfect for dynamic environments, weather effects, time-of-day changes, and material state transitions.
Key Features
- 🌊 Smooth Transitions: Interpolate between material states with full control
- ⏱️ Temporal Control: Control transition speed and timing
- 🎨 Property Interpolation: Different interpolation strategies per property type
- 🔄 State Management: Track transition progress and state
- 🚀 Runtime Flexibility: Start, cancel, or modify transitions at any time
Basic Usage
Creating a Dynamic Material
import { DynamicLayeredMaterial } from '@interverse/three-layered-material';
// Create dynamic material with initial state
const dynamicMaterial = new DynamicLayeredMaterial({
layers: [
{
name: 'Ground',
map: { color: dryGroundTexture, normal: groundNormal },
scale: 1.0,
roughness: 0.8,
metalness: 0.0
}
]
});Simple Transition
// Define target state
const wetGroundConfig = [
{
name: 'Wet Ground',
map: { color: wetGroundTexture, normal: wetGroundNormal },
scale: 0.8,
roughness: 0.3, // Wet surfaces are smoother
metalness: 0.1 // Water reflection effect
}
];
// Start transition over 3 seconds
dynamicMaterial.setTransition(wetGroundConfig, 0.5); // 50% progress
// Complete transition
setTimeout(() => {
dynamicMaterial.setTransition(wetGroundConfig, 1.0); // 100% complete
}, 3000);API Reference
Constructor
new DynamicLayeredMaterial(options: LayeredMaterialOptions)Parameters:
options: StandardLayeredMaterialoptions for initial state
Example:
const material = new DynamicLayeredMaterial({
layers: [
{ name: 'Base', map: { color: baseColor }, roughness: 0.5 }
],
blendSharpness: 8.0
});Instance Methods
setTransition(targetLayers: LayerConfig[], factor: number): void
Start or update a transition to target layers.
Parameters:
targetLayers: Array of layer configurations for the target statefactor: Transition progress (0 = source, 1 = target)
Example:
// Gradual transition over time
let progress = 0;
function updateTransition() {
progress += 0.01;
material.setTransition(targetLayers, progress);
if (progress < 1) requestAnimationFrame(updateTransition);
}
updateTransition();completeTransition(): void
Immediately complete the current transition.
// Snap to target state
material.completeTransition();cancelTransition(): void
Cancel current transition and revert to source state.
// Abort transition
material.cancelTransition();getTransitionProgress(): number
Get current transition progress (0-1).
const progress = material.getTransitionProgress();
console.log(`Transition ${(progress * 100).toFixed(1)}% complete`);isTransitioning(): boolean
Check if material is currently transitioning.
if (material.isTransitioning()) {
console.log('Material is currently changing');
}Advanced Usage
1. Weather System Transitions
class WeatherMaterialSystem {
private material: DynamicLayeredMaterial;
private currentWeather: string = 'clear';
constructor() {
this.material = new DynamicLayeredMaterial({
layers: [
{
name: 'Terrain',
map: { color: clearTerrainTexture, normal: terrainNormal },
scale: 1.0,
roughness: 0.7
},
{
name: 'Vegetation',
map: { color: clearVegetationTexture },
scale: 0.5,
roughness: 0.9
}
]
});
}
setWeather(weatherType: 'clear' | 'rainy' | 'snowy', duration: number = 5) {
const targetConfig = this.getWeatherConfig(weatherType);
this.currentWeather = weatherType;
// Animate transition
this.animateTransition(targetConfig, duration);
}
private getWeatherConfig(weatherType: string): LayerConfig[] {
const configs = {
clear: [
{ scale: 1.0, roughness: 0.7, metalness: 0.0 },
{ scale: 0.5, roughness: 0.9 }
],
rainy: [
{
scale: 0.8,
roughness: 0.3, // Wet surfaces are smoother
metalness: 0.2 // Water reflections
},
{
scale: 0.6,
roughness: 0.4 // Wet vegetation
}
],
snowy: [
{
scale: 1.2,
roughness: 0.2, // Smooth snow
metalness: 0.0
},
{
scale: 0.1, // Less visible vegetation
roughness: 0.3
}
]
};
return configs[weatherType];
}
private animateTransition(targetConfig: LayerConfig[], duration: number) {
const startTime = Date.now();
const endTime = startTime + duration * 1000;
const update = () => {
const now = Date.now();
const progress = Math.min((now - startTime) / (duration * 1000), 1);
this.material.setTransition(targetConfig, progress);
if (progress < 1) {
requestAnimationFrame(update);
} else {
this.material.completeTransition();
}
};
update();
}
}
// Usage
const weatherSystem = new WeatherMaterialSystem();
weatherSystem.setWeather('rainy', 3); // Transition to rainy over 3 seconds2. Time-of-Day System
class TimeOfDayMaterial extends DynamicLayeredMaterial {
private time: number = 0; // 0-1, where 0=midnight, 0.5=noon
constructor() {
super({
layers: [
{
name: 'Day Material',
map: { color: dayTexture, normal: dayNormal },
roughness: 0.6,
metalness: 0.0
}
]
});
}
setTimeOfDay(time: number) {
this.time = time;
// Calculate day/night blend factor
const dayFactor = Math.sin(time * Math.PI); // 0 at night, 1 at day
const nightConfig = [
{
map: { color: nightTexture, normal: nightNormal },
roughness: 0.8, // Rougher at night (dew, moisture)
metalness: 0.1 // Slight metallic for moonlight
}
];
this.setTransition(nightConfig, 1 - dayFactor);
}
update(deltaTime: number) {
// Advance time (1 unit = 24 hours)
this.time += deltaTime / (24 * 60 * 60);
this.time %= 1;
this.setTimeOfDay(this.time);
}
}
// Usage in game loop
const timeMaterial = new TimeOfDayMaterial();
function gameLoop(deltaTime: number) {
timeMaterial.update(deltaTime);
}3. Damage & Wear Progression
class DamageableMaterial extends DynamicLayeredMaterial {
private health: number = 1.0;
constructor() {
super({
layers: [
{
name: 'Healthy Material',
map: { color: healthyTexture, normal: healthyNormal },
roughness: 0.3,
metalness: 0.0,
edgeWear: {
enable: true,
intensity: 0.5,
threshold: 0.1
}
}
]
});
}
takeDamage(damage: number) {
this.health = Math.max(0, this.health - damage);
this.updateMaterialState();
}
repair(amount: number) {
this.health = Math.min(1, this.health + amount);
this.updateMaterialState();
}
private updateMaterialState() {
const damagedConfig = [
{
roughness: 0.3 + (1 - this.health) * 0.6, // 0.3 to 0.9
metalness: (1 - this.health) * 0.8, // 0.0 to 0.8
edgeWear: {
enable: true,
intensity: 0.5 + (1 - this.health) * 2.0, // 0.5 to 2.5
threshold: 0.1 - (1 - this.health) * 0.08, // 0.1 to 0.02
color: {
r: 0.7 + (1 - this.health) * 0.3,
g: 0.6,
b: 0.5 - (1 - this.health) * 0.3
}
}
}
];
this.setTransition(damagedConfig, 1 - this.health);
}
}
// Usage
const armorMaterial = new DamageableMaterial();
armorMaterial.takeDamage(0.3); // 30% damaged
armorMaterial.repair(0.1); // Repair 10%4. Seasonal Transitions
class SeasonalMaterial extends DynamicLayeredMaterial {
private season: number = 0; // 0=spring, 1=summer, 2=autumn, 3=winter
constructor() {
super({
layers: [
{
name: 'Spring Ground',
map: { color: springGroundTexture },
scale: 1.0,
roughness: 0.6
},
{
name: 'Spring Vegetation',
map: { color: springVegetationTexture },
scale: 0.5,
roughness: 0.8
}
]
});
}
setSeason(season: number, transitionDuration: number = 10) {
const targetConfig = this.getSeasonConfig(season);
// Animate seasonal transition
this.animateSeasonTransition(targetConfig, transitionDuration);
this.season = season;
}
private getSeasonConfig(season: number): LayerConfig[] {
const seasons = {
0: [ // Spring
{ scale: 1.0, roughness: 0.6 },
{ scale: 0.5, roughness: 0.8 }
],
1: [ // Summer
{ scale: 0.8, roughness: 0.7 },
{ scale: 0.7, roughness: 0.9 }
],
2: [ // Autumn
{ scale: 1.2, roughness: 0.8 },
{
scale: 0.4,
roughness: 0.7,
map: { color: autumnVegetationTexture } // Different texture
}
],
3: [ // Winter
{ scale: 1.5, roughness: 0.3 },
{ scale: 0.1, roughness: 0.4 }
]
};
return seasons[season];
}
private animateSeasonTransition(targetConfig: LayerConfig[], duration: number) {
let progress = 0;
const startTime = Date.now();
const animate = () => {
const elapsed = (Date.now() - startTime) / 1000;
progress = Math.min(elapsed / duration, 1);
this.setTransition(targetConfig, progress);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
this.completeTransition();
}
};
animate();
}
}
// Usage
const seasonalMaterial = new SeasonalMaterial();
seasonalMaterial.setSeason(2, 5); // Transition to autumn over 5 seconds5. Interactive Material States
class InteractiveMaterial extends DynamicLayeredMaterial {
private states: Map<string, LayerConfig[]> = new Map();
private currentState: string = 'default';
constructor() {
super({ layers: [] });
this.initializeStates();
}
private initializeStates() {
// Define different material states
this.states.set('default', [
{ name: 'Default', map: { color: defaultTexture }, roughness: 0.5 }
]);
this.states.set('highlighted', [
{
name: 'Highlighted',
map: { color: highlightedTexture },
roughness: 0.3,
metalness: 0.2
}
]);
this.states.set('selected', [
{
name: 'Selected',
map: { color: selectedTexture },
roughness: 0.2,
metalness: 0.4
}
]);
this.states.set('disabled', [
{
name: 'Disabled',
map: { color: disabledTexture },
roughness: 0.8,
metalness: 0.0
}
]);
}
setState(state: string, instant: boolean = false) {
const targetConfig = this.states.get(state);
if (!targetConfig) return;
this.currentState = state;
if (instant) {
this.completeTransition();
this.setTransition(targetConfig, 1.0);
} else {
// Smooth transition
this.animateStateTransition(targetConfig);
}
}
private animateStateTransition(targetConfig: LayerConfig[]) {
let progress = 0;
const animate = () => {
progress += 0.05;
this.setTransition(targetConfig, progress);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
this.completeTransition();
}
};
animate();
}
getCurrentState(): string {
return this.currentState;
}
}
// Usage
const interactiveMaterial = new InteractiveMaterial();
interactiveMaterial.setState('highlighted'); // Smooth highlight
interactiveMaterial.setState('selected', true); // Instant selectionPerformance Considerations
1. Transition Optimization
// ✅ Good - Batch transitions
material.setTransition(targetConfig, progress);
// ❌ Avoid - Rapid small updates
for (let i = 0; i <= 100; i++) {
material.setTransition(targetConfig, i / 100);
}
// ✅ Better - Throttled updates
let lastUpdate = 0;
function updateTransition(progress: number) {
const now = Date.now();
if (now - lastUpdate > 16) { // ~60fps
material.setTransition(targetConfig, progress);
lastUpdate = now;
}
}2. Texture Management
class OptimizedDynamicMaterial extends DynamicLayeredMaterial {
private textureCache: Map<string, THREE.Texture> = new Map();
preloadTextures(configs: LayerConfig[][]) {
configs.forEach(config => {
config.forEach(layer => {
if (layer.map?.color) {
this.cacheTexture(layer.map.color);
}
// Cache other textures...
});
});
}
private cacheTexture(texture: THREE.Texture) {
const key = texture.uuid;
if (!this.textureCache.has(key)) {
this.textureCache.set(key, texture);
}
}
}
// Preload all transition states
const material = new OptimizedDynamicMaterial({ layers: initialConfig });
material.preloadTextures([sunnyConfig, rainyConfig, snowyConfig]);3. Memory Management
// Clean up when done
material.dispose();
// Monitor transition states
const transitionCount = /* count active transitions */;
if (transitionCount > 10) {
console.warn('Too many simultaneous transitions');
}Common Patterns
1. Cyclic Transitions
class CyclicMaterial extends DynamicLayeredMaterial {
private cycleConfigs: LayerConfig[][];
private currentCycle: number = 0;
startCycling(interval: number = 2000) {
setInterval(() => {
this.currentCycle = (this.currentCycle + 1) % this.cycleConfigs.length;
this.setTransition(this.cycleConfigs[this.currentCycle], 1);
}, interval);
}
}2. Trigger-Based Transitions
class TriggerMaterial extends DynamicLayeredMaterial {
private triggers: Map<string, { config: LayerConfig[], duration: number }> = new Map();
addTrigger(name: string, config: LayerConfig[], duration: number = 1) {
this.triggers.set(name, { config, duration });
}
trigger(name: string) {
const trigger = this.triggers.get(name);
if (trigger) {
this.animateTransition(trigger.config, trigger.duration);
}
}
}
// Usage
material.addTrigger('hit', hitConfig, 0.5);
material.addTrigger('powerup', powerupConfig, 2.0);
// Trigger effects
material.trigger('hit'); // Quick hit effect
material.trigger('powerup'); // Longer powerup effect3. Layered Transitions
class LayeredTransitionMaterial extends DynamicLayeredMaterial {
private activeTransitions: Map<string, number> = new Map();
addLayerTransition(layerId: string, targetConfig: LayerConfig[], progress: number) {
this.activeTransitions.set(layerId, progress);
this.updateCombinedTransition();
}
private updateCombinedTransition() {
// Combine multiple layer transitions
const combinedConfig = /* merge based on activeTransitions */;
this.setTransition(combinedConfig, 1.0);
}
}The DynamicLayeredMaterial transforms static materials into living, breathing surfaces that can respond to game events, environmental changes, and player interactions in real-time!
🎯 Purpose & Behavior
Decals
- Purpose: Surface applications that sit on top of the material
- Behavior: Like stickers, paint, logos, or temporary markings
- Relationship: Additive - they add new visual elements
- Examples:
- Company logos on vehicles
- Graffiti on walls
- Temporary paint markings
- Blood splatters (in games)
- Mud splashes
Damages
- Purpose: Surface alterations that reveal underlying materials
- Behavior: Like wear, erosion, or material removal
- Relationship: Subtractive/Revealing - they expose what's underneath
- Examples:
- Scratches showing metal under paint
- Worn edges revealing wood under varnish
- Corrosion eating through surfaces
- Cracks exposing interior materials
🔧 Technical Differences
Decals
// Decals typically BLEND or OVERWRITE
blendMode: {
color: 'normal', // Complete color replacement
normal: 'normal', // Overwrite normals
roughness: 'normal', // Direct replacement
}
// Result: Decal appears ON TOP of existing materialDamages
// Damages typically REVEAL or MODIFY
blendMode: {
color: 'multiply', // Darken or reveal underlying
normal: 'rnb', // Blend normals to show depth
roughness: 'max', // Make areas rougher
}
// Result: Damage shows WHAT'S UNDERNEATH existing material🎨 Visual Characteristics
Decals
- Opacity: Usually opaque or semi-transparent
- Edges: Sharp or soft, but clearly defined boundaries
- Depth: Sit on the surface plane
- Interaction: Don't affect underlying material properties much
Damages
- Opacity: Often reveal completely different materials
- Edges: Organic, irregular, based on wear patterns
- Depth: Show actual material depth (scratches, dents)
- Interaction: Significantly alter material properties (roughness, metalness)
⏱️ Temporal Behavior
Decals
// Often temporary or removable
{
lifetime: 30, // Fades after 30 seconds
fadeStartTime: Date.now(),
priority: 1 // Can be layered
}Damages
// Often permanent or slowly healing
{
permanent: false,
healRate: 0.02, // Very slow natural healing
intensity: 0.8 // Current damage level
}🎪 Real-World Examples
Decals in Action:
// Adding a racing stripe to a car
material.addDecal({
position: new THREE.Vector3(0, 1.2, 0),
size: new THREE.Vector3(2, 0.1, 0.1),
layer: {
map: { color: racingStripeTexture },
// Doesn't change the car's material properties
}
});
// Adding a temporary mud splatter
material.addDecal({
position: new THREE.Vector3(0, 0.5, -1),
lifetime: 60, // Washes off after 60 seconds
layer: {
map: { color: mudTexture, roughness: mudRoughness },
// Mud sits on top of existing paint
}
});Damages in Action:
// Adding wear on door edges
material.addDamage({
type: 'scratch',
position: new THREE.Vector3(0.8, 0.5, 0),
layer: {
map: {
color: exposedMetalColor, // Shows metal under paint
roughness: scratchedRoughness, // Rougher than paint
metalness: 0.8 // Metal is more metallic
},
// Actually changes the material properties
}
});
// Adding corrosion damage
material.addDamage({
type: 'corrosion',
position: new THREE.Vector3(-0.5, 0.3, 0),
layer: {
map: {
color: rustColor, // Rust replaces paint
normal: corrodedNormal, // Pitted surface
roughness: 0.9 // Very rough corroded surface
},
// Permanently alters the material
}
});🔄 Material Interaction
Decals
Base Material → Decal Applied
[Paint] + [Logo] = [Paint with Logo on top]
Properties mostly unchangedDamages
Base Material → Damage Applied
[Paint over Metal] + [Scratch] = [Paint with Metal showing through]
Properties significantly changed in damaged areas🎮 Game Context Examples
Decals (Temporary Effects):
- Blood splatters after combat
- Bullet impact marks on walls
- Footprints in snow/mud
- Temporary spray paint
- Projected light patterns
Damages (Permanent Changes):
- Armor wear showing underlying metal
- Weapon scratches from usage
- Vehicle denting from collisions
- Building erosion from weather
- Character scarring from injuries
💡 When to Use Which
Use Decals When:
- Adding temporary visual elements
- Applying surface markings that don't alter material
- Need quick runtime application/removal
- Want layered visual effects (multiple decals)
Use Damages When:
- Showing material wear and aging
- Revealing underlying material layers
- Creating permanent surface alterations
- Simulating physical erosion or destruction
🔧 Hybrid Approach
Sometimes you might use both together:
// A bullet hole might be both:
// - A decal (the impact mark on the surface)
// - A damage (the exposed material underneath)
// Impact decal (surface marking)
material.addDecal({
position: bulletHitPos,
layer: { map: { color: impactSmoke } },
lifetime: 5
});
// Structural damage (material alteration)
material.addDamage({
type: 'bullet',
position: bulletHitPos,
layer: {
map: {
color: exposedMetalColor,
roughness: 0.9,
metalness: 0.8
}
},
permanent: true
});🎯 Quick Reference
| Aspect | Decals | Damages | |--------|---------|----------| | Purpose | Add surface elements | Reveal underlying materials | | Blending | Normal/Overlay | Multiply/Reveal | | Duration | Often temporary | Often permanent | | Effect | Visual addition | Material alteration | | Examples | Logos, paint, blood | Scratches, corrosion, wear |
TL;DR: Decals add to the surface, damages reveal what's underneath!
