Skip to content

Materials as assets and property type#5846

Open
vincentfretin wants to merge 10 commits into
aframevr:masterfrom
vincentfretin:materal-asset
Open

Materials as assets and property type#5846
vincentfretin wants to merge 10 commits into
aframevr:masterfrom
vincentfretin:materal-asset

Conversation

@vincentfretin

@vincentfretin vincentfretin commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #5457

Description

Materials can now be defined once in <a-assets> and shared across any number of entities, instead of every entity creating its own THREE.Material:

<a-assets>
  <img id="wood-texture" src="wood.png">
  <a-material id="wood" src="#wood-texture" roughness="0.8"></a-material>
  <a-material id="red" shader="flat" color="red"></a-material>
</a-assets>

<!-- Both boxes share the exact same THREE.Material instance. -->
<a-box material="material: #wood"></a-box>
<a-box material="material: #wood"></a-box>

<a-material> asset element (src/core/a-material.js)

  • Creates the material through the registered shader system (standard, flat, or any custom shader) during scene loading, including its textures; <a-assets> blocks scene rendering until the material and its textures are ready.
  • Properties are individual HTML attributes combining the material component base schema and the selected shader's schema. Attribute names are matched case-insensitively against the schema (HTML lowercases attributes, e.g. normalmapnormalMap).
  • Changing an attribute after creation updates the shared material live for every entity using it (shader cannot be changed after creation).
  • Removing the element from the DOM disposes the material and its textures.

material property type (src/core/propertyTypes.js)

Resolves an ID selector to the underlying shared THREE.Material. Any component can accept a material in its schema (e.g. the hand-tracking-controls use case from the issue):

AFRAME.registerComponent('my-component', {
  schema: {highlightMaterial: {type: 'material'}}
});

It also accepts a THREE.Material instance directly, and inline definitions (below). Stringification round-trips to #id / material(...) for flushToDOM.

Material component integration (src/components/material.js)

  • New material property: material="material: #wood". When set, the shared material is used as-is and all other material component properties are ignored (the asset fully defines the material).
  • The component does not take ownership of shared materials: it never disposes/re-registers them, and remove()/material switching leaves them intact. Setting material back to empty returns the entity to its own component-managed material.
  • Works with primitives: <a-box material="material: #wood">.

Inline material(...) definitions

For one-off materials, the property type also accepts an inline definition (the optional syntax proposed in the issue):

<a-entity hand-tracking-controls="hand: right; handMaterial: material(shader: flat; color: red)"></a-entity>
<a-box material="material: material(color: #8B4513; roughness: 0.9)"></a-box>

Each inline definition is backed by an <a-material> element automatically attached under the scene's <a-assets>; identical definition strings are cached and share a single THREE.Material instance.

To support semicolons inside the parentheses, styleParser no longer splits style strings inside parenthesized values — a generalization of the existing unclosed-url( special case for data URIs.

Commits

  1. Move shared material helpers to utils/material — pure refactor: parseSide, parseBlending, disposeMaterial and base property assignment (updateBaseMaterial) extracted from the material component for reuse.
  2. Add <a-material> asset element and material property type — the core feature, tests, docs, examples.
  3. Fix primitive mapping collision with material material property — the new schema property collided with the material component name in auto-generated mesh primitive mappings, spamming a "mapping collision" warning per primitive.
  4. Fix <a-material> texture loading when created before scene systems exist — on-demand creation by an early property parse (e.g. setAttribute at DOMContentLoaded) crashed on the missing material system; texture loading is now deferred until the system is available.
  5. styleParser: don't split style strings inside parentheses.
  6. Support inline material() definitions in the material property type.
  7. Fix styleParser re-entrancy corrupting the pooled chunks array — found while testing the inspector: the component attrValueProxy setter synchronously parses the inline material(...) value, and that nested parse clobbered the pooled chunks array of the outer parse (leaking inner keys into attrValue, dropping outer properties).

Performance

Measured with examples/test/material-asset/perf.html (Chrome, Linux, 500-frame sample; ?shared=&n= to reproduce):

shared <a-material> per-entity materials
2000 boxes — load time 788–845 ms 1150 ms
8000 boxes — frame avg / p50 16.9 / 16.7 ms (60 fps) 25.0 / 24 ms (~40 fps)
8000 boxes — unique materials 2 8001
8000 boxes — settled JS heap 103 MB 326 MB

GL program count and GPU texture count are identical in both modes (three.js already deduplicates programs, and the texture source cache deduplicates uploads), so the wins are per-material CPU overhead in the render loop, loading time, and memory.

Tests

  • tests/core/a-material.test.js (13 tests): creation from attributes, shader defaults, texture load blocking, instance sharing, live attribute updates, dispose ownership (entity switch/removal vs asset removal), early-creation regression, inline materials (cache sharing, single-property schema).
  • tests/core/propertyTypes.test.js: material type parse/stringify incl. inline round-trip.
  • tests/extras/primitives/primitives.test.js: no material/material-material primitive mapping.
  • tests/utils/styleParser.test.js (new file): paren-aware splitting incl. data URIs and nested parens.

Full suite: 1200+ passing; the only failures are the 2 look-controls touch-drag tests that fail identically on master in this environment (canvas-size assertion, unrelated).

Docs & examples

  • New docs/primitives/a-material.md; updates to docs/components/material.md, docs/core/component.md (property types table), docs/core/asset-management-system.md.
  • examples/test/material-asset/index.html (demo: shared texture/PBR/transparent materials, inline materials) and examples/test/material-asset/perf.html (benchmark used for the numbers above).

🤖 Generated with Claude Code

vincentfretin and others added 6 commits July 4, 2026 09:38
Extract parseSide, parseBlending, disposeMaterial and the base material
property assignment (updateBaseMaterial) from the material component to
utils/material.js so they can be reused outside the component. No
behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mevr#5457)

Materials can now be defined once in <a-assets> and shared across
entities:

  <a-assets>
    <a-material id="wood" src="#woodTexture" roughness="0.8"></a-material>
  </a-assets>
  <a-box material="material: #wood"></a-box>

- <a-material> creates a THREE.Material through the registered shaders
  (standard, flat, custom) during scene loading, including textures,
  and <a-assets> blocks rendering until it is ready.
- New `material` property type resolves an ID selector to the shared
  THREE.Material so any component can accept a material in its schema.
- The material component accepts a `material` property; when set, the
  shared material is used as-is and the component does not own
  (dispose/register) it.
- Attribute changes on <a-material> update the shared material live for
  every entity using it; removing the element disposes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The new `material` property in the material component schema collided
with the material component name in the auto-generated mesh primitive
mappings, triggering a "mapping collision" console warning and a
useless `material-material` attribute on every mesh primitive. Exclude
the property from the mappings; primitives set it through
`material="material: #ref"` like any other entity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The `material` property type creates the material on demand. When that
parse happens before the scene initialized its systems (e.g., an entity
setAttribute at DOMContentLoaded referencing the material asset),
texture loading crashed on the missing material system. Defer the
texture-loading shader update until the material system is available;
it then runs when the <a-material> connects to the loaded scene.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generalize the unclosed-url() special case (added for data URIs) to any
parenthesized value, so function-like values such as the upcoming
inline `material(...)` syntax can contain semicolons:

  material="material: material(shader: flat; color: red); side: double"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In addition to referencing an <a-material> asset by selector, the
`material` property type accepts an inline definition:

  <a-entity hand-tracking-controls="hand: right;
      handMaterial: material(shader: flat; color: red)">
  <a-box material="material: material(color: #8B4513; roughness: 0.9)">

Each inline definition is backed by an <a-material> element attached
under the scene's <a-assets>, and identical definitions are cached so
they share a single THREE.Material instance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
styleParse pools its chunks array across calls. Assigning a parsed
value into the target object can synchronously trigger a nested parse
(the component attrValueProxy setter parsing an inline `material(...)`
definition), which clobbered the pooled array the outer call was still
iterating, leaking inner keys into the outer object and dropping outer
chunks. Nested calls now use their own array.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vincentfretin added a commit to vincentfretin/aframe-inspector that referenced this pull request Jul 4, 2026
Two <a-material> assets (PBR and textured) shared across entities, plus
an inline material(...) definition, to test the material property type.
Requires an A-Frame build with aframevr/aframe#5846; with older builds
these entities render with a default material and a warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread docs/components/material.md Outdated
| alphaTest | Alpha test threshold for transparency. | 0 |
| depthTest | Whether depth testing is enabled when rendering the material. | true |
| flatShading | Use `THREE.FlatShading` rather than `THREE.StandardShading`. | false |
| material | Selector to a [`<a-material>` asset][amaterial] to use as shared material (e.g., `#wood`). When set, all other properties are ignored and the material is entirely defined by the asset. | None |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: an <a-material>

I'd also drop the word "shared". While one of the primary benefits is material sharing, it's not inherently required. Nothing wrong with a user creating an <a-material> and only using it for a single object, for example.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 93d7c09: "an <a-material>" and dropped the "shared" wording.

Comment thread docs/core/asset-management-system.md Outdated
@@ -22,6 +22,7 @@ We place assets within `<a-assets>`, and we place `<a-assets>` within
`<a-scene>`. Assets include:

- `<a-asset-item>` - Miscellaneous assets such as 3D models and materials

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This description should probably no longer mention materials to avoid confusion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 93d7c09, the <a-asset-item> line no longer mentions materials.

Comment thread docs/core/asset-management-system.md Outdated
`<a-scene>`. Assets include:

- `<a-asset-item>` - Miscellaneous assets such as 3D models and materials
- `<a-material>` - Shared materials, created (with their textures) at load time

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: I don't think "shared" needs to be highlighted here, as this applies to most assets. You can "share" the same audio, image tag as well. Something simple like "Materials and their textures" probably suffices.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 93d7c09, now reads "Materials and their textures".

Comment thread src/utils/styleParser.js Outdated
var sep = ';';
// Re-entrant calls must not clobber the pooled array an outer call is
// still iterating over.
var chunks = reentrant ? [] : pooledChunks;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of instantiating new empty arrays for nested calls, we could turn pooledChunks into an array of arrays. One entry for each level.

In fact, we could even consider limiting the amount of "nesting" we allow. Maybe even as low as 2 levels, and throwing an Error if this is exceeded. With the idea that under normal usage you wouldn't encounter this (or if you did, you probably wouldn't want it).

For the getKeyValueChunks method it could receive the chunks array to use and the styleParse function could pass it using pooledChunks[parseDepth] (as well as throw if we introduce an upper bound)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, done in 6f786c5: pooledChunks is now an array of arrays indexed by parse depth, getKeyValueChunks receives the chunks array to use, and nesting is capped at two levels (outer style string + nested parse from a property assignment, e.g. an inline material value) — exceeding the cap throws. parseDepth is decremented in a finally so a throwing property setter cannot poison subsequent parses. Added a test for the cap and recovery.

Comment thread src/core/a-material.js Outdated
Comment on lines +90 to +92
if (key === 'alphaTest' || key === 'side' || key === 'vertexColorsEnabled') {
this.material.needsUpdate = true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor point, but the material would now also be marked as needing an update if the attribute is changed to the same value, as attributeChangedCallback still fires in that case.

But looking at it more closely, I think we could move this logic into updateBaseMaterial and actually check if any of the relevant properties changed. That would also make the logic in material.js easier, as it now bases it on the oldData properties, whereas the material itself should be the source of truth.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, done in ef50899: updateBaseMaterial now compares against the material itself and only flags needsUpdate when alphaTest/side/vertexColors actually change. That removed the oldData diff from the material component (updateMaterial is now a one-liner) and the explicit flag in <a-material>'s attributeChangedCallback. Added a test that setting an attribute to its current value does not bump material.version while a real change does.

vincentfretin and others added 3 commits July 4, 2026 11:17
Fix article (an <a-material>), drop the "shared" emphasis since sharing
is a benefit but not a requirement, and stop mentioning materials in
the <a-asset-item> description to avoid confusion with <a-material>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Instead of allocating a fresh array for nested parses, pool one chunks
array per nesting level and pass it to getKeyValueChunks. Nesting is
capped at two levels (an outer style string and a nested parse
triggered by a property assignment, e.g. an inline material value);
exceeding the cap throws. parseDepth unwinds in a finally block so a
throwing property setter cannot poison subsequent parses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flag needsUpdate only when a property requiring a program rebuild
(alphaTest, side, vertexColors) actually changes, comparing against the
material itself as the source of truth. This removes the oldData diff
from the material component and stops <a-material> from flagging a
rebuild when an attribute is set to its current value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vincentfretin

Copy link
Copy Markdown
Contributor Author

Changes for the inspector: aframevr/aframe-inspector#859

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Materials as assets and property type

2 participants