|
| 1 | +# Material Flat API |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +The Material component provides a simplified, flattened interface for accessing and modifying Material component properties. It eliminates the need to navigate deeply nested union structures (`PBR` vs `Unlit`, `texture` vs `avatarTexture` vs `videoTexture`), making material manipulation more intuitive and less error-prone. |
| 6 | + |
| 7 | +### API Methods |
| 8 | + |
| 9 | +The Material component provides four flat accessor methods, following the standard ECS component pattern: |
| 10 | + |
| 11 | +| Method | Returns | Missing Behavior | Use Case | |
| 12 | +|--------|---------|------------------|----------| |
| 13 | +| `getFlat(entity)` | `ReadonlyFlatMaterial` | Throws | Read-only access, component must exist | |
| 14 | +| `getFlatOrNull(entity)` | `ReadonlyFlatMaterial \| null` | Returns `null` | Read-only access, check existence | |
| 15 | +| `getFlatMutable(entity)` | `FlatMaterial` | Throws | Read/write access, component must exist | |
| 16 | +| `getFlatMutableOrNull(entity)` | `FlatMaterial \| null` | Returns `null` | Read/write access, check existence | |
| 17 | + |
| 18 | +### Compile-Time Sync Validation |
| 19 | + |
| 20 | +The `FlatMaterial` interface includes compile-time type assertions that ensure it stays in sync with the generated protobuf types (`PBMaterial_PbrMaterial` and `PBMaterial_UnlitMaterial`). If a new property is added to the protobuf definitions, the build will fail with a descriptive error until `FlatMaterial` is updated: |
| 21 | + |
| 22 | +```typescript |
| 23 | +// Example error when 'glossiness' is added to PBMaterial_PbrMaterial but not to FlatMaterial: |
| 24 | +// Type 'boolean' is not assignable to type '{ error: "FlatMaterial is missing PBR properties"; missing: "glossiness"; }' |
| 25 | +``` |
| 26 | + |
| 27 | +This ensures the API stays complete as the Material schema evolves. |
| 28 | + |
| 29 | +## Motivation |
| 30 | + |
| 31 | +The Material component in Decentraland SDK uses a complex nested structure with discriminated unions: |
| 32 | + |
| 33 | +```typescript |
| 34 | +// Before: Complex nested access pattern |
| 35 | +const mat = Material.get(entity) |
| 36 | +if (mat.material?.$case === 'pbr') { |
| 37 | + if (mat.material.pbr.texture?.tex?.$case === 'texture') { |
| 38 | + const src = mat.material.pbr.texture.tex.texture.src |
| 39 | + } |
| 40 | +} |
| 41 | +``` |
| 42 | + |
| 43 | +```typescript |
| 44 | +// After: Simple flat access pattern |
| 45 | +const src = Material.getFlat(entity).texture.src |
| 46 | +``` |
| 47 | + |
| 48 | +## API Reference |
| 49 | + |
| 50 | +### `Material.getFlat(entity: Entity): ReadonlyFlatMaterial` |
| 51 | + |
| 52 | +Returns a readonly `FlatMaterial` accessor object that provides direct read access to material properties. Does NOT mark the component as dirty. |
| 53 | + |
| 54 | +**Throws:** Error if the entity does not have a Material component. |
| 55 | + |
| 56 | +### `Material.getFlatOrNull(entity: Entity): ReadonlyFlatMaterial | null` |
| 57 | + |
| 58 | +Returns a readonly `FlatMaterial` accessor object, or `null` if the entity does not have a Material component. Does NOT mark the component as dirty. |
| 59 | + |
| 60 | +### `Material.getFlatMutable(entity: Entity): FlatMaterial` |
| 61 | + |
| 62 | +Returns a mutable `FlatMaterial` accessor object that provides direct read/write access to material properties. Marks the component as dirty (for change detection). |
| 63 | + |
| 64 | +**Throws:** Error if the entity does not have a Material component. |
| 65 | + |
| 66 | +### `Material.getFlatMutableOrNull(entity: Entity): FlatMaterial | null` |
| 67 | + |
| 68 | +Returns a mutable `FlatMaterial` accessor object, or `null` if the entity does not have a Material component. Marks the component as dirty if component exists. |
| 69 | + |
| 70 | +### FlatMaterial Interface |
| 71 | + |
| 72 | +```typescript |
| 73 | +interface FlatMaterial { |
| 74 | + // Texture accessors (all return FlatTexture) |
| 75 | + readonly texture: FlatTexture // Main texture (PBR + Unlit) |
| 76 | + readonly alphaTexture: FlatTexture // Alpha texture (PBR + Unlit) |
| 77 | + readonly emissiveTexture: FlatTexture // Emissive texture (PBR only) |
| 78 | + readonly bumpTexture: FlatTexture // Bump/normal texture (PBR only) |
| 79 | + |
| 80 | + // Shared properties (PBR + Unlit) |
| 81 | + alphaTest: number | undefined // Alpha test threshold (0-1) |
| 82 | + castShadows: boolean | undefined // Whether material casts shadows |
| 83 | + |
| 84 | + // PBR-only properties |
| 85 | + albedoColor: Color4 | undefined // Base color |
| 86 | + emissiveColor: Color3 | undefined // Emissive/glow color |
| 87 | + reflectivityColor: Color3 | undefined // Reflectivity color |
| 88 | + transparencyMode: MaterialTransparencyMode | undefined |
| 89 | + metallic: number | undefined // Metallic value (0-1) |
| 90 | + roughness: number | undefined // Roughness value (0-1) |
| 91 | + specularIntensity: number | undefined // Specular intensity |
| 92 | + emissiveIntensity: number | undefined // Emissive intensity |
| 93 | + directIntensity: number | undefined // Direct light intensity |
| 94 | + |
| 95 | + // Unlit-only property |
| 96 | + diffuseColor: Color4 | undefined // Diffuse color (Unlit only) |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +### ReadonlyFlatMaterial Interface |
| 101 | + |
| 102 | +The `ReadonlyFlatMaterial` interface is identical to `FlatMaterial` but with all properties marked as `readonly`, preventing mutations. |
| 103 | + |
| 104 | +### FlatTexture Interface |
| 105 | + |
| 106 | +```typescript |
| 107 | +interface FlatTexture { |
| 108 | + src: string | undefined |
| 109 | + wrapMode: TextureWrapMode | undefined |
| 110 | + filterMode: TextureFilterMode | undefined |
| 111 | +} |
| 112 | +``` |
| 113 | + |
| 114 | +### ReadonlyFlatTexture Interface |
| 115 | + |
| 116 | +```typescript |
| 117 | +interface ReadonlyFlatTexture { |
| 118 | + readonly src: string | undefined |
| 119 | + readonly wrapMode: TextureWrapMode | undefined |
| 120 | + readonly filterMode: TextureFilterMode | undefined |
| 121 | +} |
| 122 | +``` |
| 123 | + |
| 124 | +## Usage Examples |
| 125 | + |
| 126 | +### Reading Properties (Readonly Access) |
| 127 | + |
| 128 | +```typescript |
| 129 | +// Read texture source - uses getFlat() for readonly access |
| 130 | +const textureSrc = Material.getFlat(entity).texture.src |
| 131 | + |
| 132 | +// Read PBR properties |
| 133 | +const metallic = Material.getFlat(entity).metallic |
| 134 | +const roughness = Material.getFlat(entity).roughness |
| 135 | +const albedoColor = Material.getFlat(entity).albedoColor |
| 136 | + |
| 137 | +// Read shared properties |
| 138 | +const alphaTest = Material.getFlat(entity).alphaTest |
| 139 | +const castShadows = Material.getFlat(entity).castShadows |
| 140 | + |
| 141 | +// Read Unlit-only property |
| 142 | +const diffuseColor = Material.getFlat(entity).diffuseColor |
| 143 | +``` |
| 144 | + |
| 145 | +### Safe Reading with getFlatOrNull |
| 146 | + |
| 147 | +```typescript |
| 148 | +// Check if material exists before reading |
| 149 | +const flat = Material.getFlatOrNull(entity) |
| 150 | +if (flat) { |
| 151 | + console.log('Texture:', flat.texture.src) |
| 152 | + console.log('Metallic:', flat.metallic) |
| 153 | +} |
| 154 | +``` |
| 155 | + |
| 156 | +### Writing Properties (Mutable Access) |
| 157 | + |
| 158 | +```typescript |
| 159 | +// Update texture source - uses getFlatMutable() for write access |
| 160 | +Material.getFlatMutable(entity).texture.src = 'newTexture.png' |
| 161 | + |
| 162 | +// Update texture wrap/filter modes |
| 163 | +Material.getFlatMutable(entity).texture.wrapMode = TextureWrapMode.TWM_MIRROR |
| 164 | +Material.getFlatMutable(entity).texture.filterMode = TextureFilterMode.TFM_TRILINEAR |
| 165 | + |
| 166 | +// Update PBR properties |
| 167 | +Material.getFlatMutable(entity).metallic = 0.9 |
| 168 | +Material.getFlatMutable(entity).roughness = 0.2 |
| 169 | +Material.getFlatMutable(entity).albedoColor = { r: 1, g: 0, b: 0, a: 1 } |
| 170 | +Material.getFlatMutable(entity).transparencyMode = MaterialTransparencyMode.MTM_ALPHA_BLEND |
| 171 | + |
| 172 | +// Update shared properties |
| 173 | +Material.getFlatMutable(entity).alphaTest = 0.5 |
| 174 | +Material.getFlatMutable(entity).castShadows = false |
| 175 | +``` |
| 176 | + |
| 177 | +### Chained Updates |
| 178 | + |
| 179 | +```typescript |
| 180 | +const flat = Material.getFlatMutable(entity) |
| 181 | +flat.texture.src = 'metal.png' |
| 182 | +flat.metallic = 0.95 |
| 183 | +flat.roughness = 0.1 |
| 184 | +flat.albedoColor = { r: 0.8, g: 0.8, b: 0.9, a: 1 } |
| 185 | +flat.castShadows = true |
| 186 | +``` |
| 187 | + |
| 188 | +### Safe Writing with getFlatMutableOrNull |
| 189 | + |
| 190 | +```typescript |
| 191 | +// Only mutate if material exists |
| 192 | +const flat = Material.getFlatMutableOrNull(entity) |
| 193 | +if (flat) { |
| 194 | + flat.metallic = 0.9 |
| 195 | + flat.roughness = 0.2 |
| 196 | +} |
| 197 | +``` |
| 198 | + |
| 199 | +### Working with Different Textures |
| 200 | + |
| 201 | +```typescript |
| 202 | +const flat = Material.getFlatMutable(entity) |
| 203 | + |
| 204 | +// Main texture |
| 205 | +flat.texture.src = 'diffuse.png' |
| 206 | + |
| 207 | +// Alpha texture |
| 208 | +flat.alphaTexture.src = 'alpha.png' |
| 209 | + |
| 210 | +// Emissive texture (PBR only) |
| 211 | +flat.emissiveTexture.src = 'glow.png' |
| 212 | + |
| 213 | +// Bump/normal texture (PBR only) |
| 214 | +flat.bumpTexture.src = 'normal.png' |
| 215 | +``` |
| 216 | + |
| 217 | +## Behavior Details |
| 218 | + |
| 219 | +### Reading Properties |
| 220 | + |
| 221 | +| Material Type | Property Type | Behavior | |
| 222 | +| ------------- | --------------------------------- | ------------------- | |
| 223 | +| PBR | PBR-only (e.g., `metallic`) | Returns the value | |
| 224 | +| PBR | Unlit-only (e.g., `diffuseColor`) | Returns `undefined` | |
| 225 | +| PBR | Shared (e.g., `alphaTest`) | Returns the value | |
| 226 | +| Unlit | PBR-only (e.g., `metallic`) | Returns `undefined` | |
| 227 | +| Unlit | Unlit-only (e.g., `diffuseColor`) | Returns the value | |
| 228 | +| Unlit | Shared (e.g., `alphaTest`) | Returns the value | |
| 229 | + |
| 230 | +### Writing Properties |
| 231 | + |
| 232 | +| Material Type | Property Type | Behavior | |
| 233 | +| ------------- | --------------------------------- | ---------------- | |
| 234 | +| PBR | PBR-only (e.g., `metallic`) | Sets the value | |
| 235 | +| PBR | Unlit-only (e.g., `diffuseColor`) | **Throws Error** | |
| 236 | +| PBR | Shared (e.g., `alphaTest`) | Sets the value | |
| 237 | +| Unlit | PBR-only (e.g., `metallic`) | **Throws Error** | |
| 238 | +| Unlit | Unlit-only (e.g., `diffuseColor`) | Sets the value | |
| 239 | +| Unlit | Shared (e.g., `alphaTest`) | Sets the value | |
| 240 | + |
| 241 | +### Texture Handling |
| 242 | + |
| 243 | +| Texture Type | Reading | Writing | |
| 244 | +| ------------------------------------------- | ------------------- | --------------------------------------- | |
| 245 | +| Regular texture (`Material.Texture.Common`) | Returns values | Sets values | |
| 246 | +| Avatar texture (`Material.Texture.Avatar`) | Returns `undefined` | **Throws Error** | |
| 247 | +| Video texture (`Material.Texture.Video`) | Returns `undefined` | **Throws Error** | |
| 248 | +| No texture set | Returns `undefined` | Creates texture structure automatically | |
| 249 | + |
| 250 | +### Auto-creation of Texture Structures |
| 251 | + |
| 252 | +When setting a texture property (e.g., `texture.src`) on a material that doesn't have a texture defined, the accessor automatically creates the necessary nested structure: |
| 253 | + |
| 254 | +```typescript |
| 255 | +// Material created without texture |
| 256 | +Material.setPbrMaterial(entity, { metallic: 0.5 }) |
| 257 | + |
| 258 | +// Setting src creates the texture structure automatically |
| 259 | +Material.getFlatMutable(entity).texture.src = 'newTexture.png' |
| 260 | + |
| 261 | +// Now the material has a proper texture structure |
| 262 | +``` |
| 263 | + |
| 264 | +## Error Handling |
| 265 | + |
| 266 | +### Entity Without Material Component |
| 267 | + |
| 268 | +```typescript |
| 269 | +const entity = engine.addEntity() |
| 270 | +// No Material.setPbrMaterial() or Material.setBasicMaterial() called |
| 271 | + |
| 272 | +Material.getFlat(entity) // Throws: Entity does not have Material component |
| 273 | +Material.getFlatMutable(entity) // Throws: Entity does not have Material component |
| 274 | + |
| 275 | +// Use OrNull variants to avoid exceptions |
| 276 | +Material.getFlatOrNull(entity) // Returns null |
| 277 | +Material.getFlatMutableOrNull(entity) // Returns null |
| 278 | +``` |
| 279 | + |
| 280 | +### Writing PBR Property on Unlit Material |
| 281 | + |
| 282 | +```typescript |
| 283 | +Material.setBasicMaterial(entity, {}) |
| 284 | +Material.getFlatMutable(entity).metallic = 0.5 // Throws: "Cannot set metallic on Unlit material. Use PBR material instead." |
| 285 | +``` |
| 286 | + |
| 287 | +### Writing Unlit Property on PBR Material |
| 288 | + |
| 289 | +```typescript |
| 290 | +Material.setPbrMaterial(entity, {}) |
| 291 | +Material.getFlatMutable(entity).diffuseColor = { r: 1, g: 0, b: 0, a: 1 } // Throws: "Cannot set diffuseColor on PBR material. Use Unlit material instead." |
| 292 | +``` |
| 293 | + |
| 294 | +### Modifying Special Textures |
| 295 | + |
| 296 | +```typescript |
| 297 | +Material.setPbrMaterial(entity, { |
| 298 | + texture: Material.Texture.Avatar({ userId: '0xabc' }) |
| 299 | +}) |
| 300 | +Material.getFlatMutable(entity).texture.src = 'test.png' // Throws: "Cannot set texture properties on Avatar texture." |
| 301 | +``` |
| 302 | + |
| 303 | +## Implementation Notes |
| 304 | + |
| 305 | +### Architecture |
| 306 | + |
| 307 | +The implementation uses four accessor classes: |
| 308 | + |
| 309 | +1. **`FlatMaterialAccessor`**: Implements `FlatMaterial` interface with getters/setters for all material properties (mutable). |
| 310 | +2. **`FlatTextureAccessor`**: Implements `FlatTexture` interface with getters/setters for texture properties (mutable). |
| 311 | +3. **`ReadonlyFlatMaterialAccessor`**: Implements `ReadonlyFlatMaterial` interface with only getters (readonly). |
| 312 | +4. **`ReadonlyFlatTextureAccessor`**: Implements `ReadonlyFlatTexture` interface with only getters (readonly). |
| 313 | + |
| 314 | +Mutable accessors hold a reference to a `getMaterial()` function that calls `getMutable()`, marking the component as dirty. |
| 315 | +Readonly accessors use `get()` or `getOrNull()`, which does NOT mark the component as dirty. |
| 316 | + |
| 317 | +### Performance Considerations |
| 318 | + |
| 319 | +- The flat accessor methods create new accessor instances on each call |
| 320 | +- The accessor uses lazy evaluation - properties are only read/written when accessed |
| 321 | +- **Readonly methods (`getFlat`, `getFlatOrNull`) do NOT mark components as dirty**, making them efficient for read-only operations |
| 322 | +- For frequent updates in hot paths, consider caching the `FlatMaterial` reference: |
| 323 | + |
| 324 | +```typescript |
| 325 | +// Cache the accessor if making many updates |
| 326 | +const flat = Material.getFlatMutable(entity) |
| 327 | +flat.metallic = 0.9 |
| 328 | +flat.roughness = 0.1 |
| 329 | +// ... more updates |
| 330 | +``` |
| 331 | + |
| 332 | +### Type Safety |
| 333 | + |
| 334 | +- PBR-only properties return `undefined` when read on Unlit materials (no runtime error) |
| 335 | +- Writing incompatible properties throws descriptive errors at runtime |
| 336 | +- TypeScript types reflect the nullable nature of all properties |
| 337 | +- Readonly accessors prevent mutations at compile-time |
0 commit comments