Materials¶
simvx.core.Material is the backend-agnostic material data SimVX feeds to
both Vulkan and the WebGPU runtime. It carries colour, PBR parameters,
blending mode, surface flags, a UV transform, and up to five texture maps.
from simvx.core import Material
red = Material(colour=(1, 0, 0, 1))
glass = Material(colour=(0.6, 0.9, 1.0, 0.4), blend="alpha")
brick = Material(albedo_map="textures/brick.png", roughness=0.8)
Constructor¶
Argument |
Type |
Default |
Notes |
|---|---|---|---|
|
RGBA (or RGB) in 0-1 |
|
Multiplied by albedo at shade time |
|
|
|
PBR metallic factor |
|
|
|
PBR roughness factor |
|
|
|
Pipeline derivative selector |
|
|
|
Alpha-test threshold for |
|
|
|
Draws the mesh’s edges instead of its faces: see-through, and casts no shadow. Line polygon mode on desktop, an edge list on web |
|
|
|
Disable backface culling |
|
|
|
Not rendered yet: stored and saved, but neither renderer draws it |
|
|
|
Diffuse texture; also a |
|
|
|
Tangent-space normal map |
|
|
|
Packed B=metal, G=rough |
|
|
|
RGB emissive |
|
|
|
Ambient occlusion |
|
3- or 4-tuple |
|
RGB or RGB+intensity |
|
|
|
Multiplier on the emissive RGB, stored as the fourth component of |
|
|
|
Samples the copied opaque scene colour (refraction); only meaningful with |
|
|
|
Samples the copied opaque scene depth |
|
|
|
Applied to every map: |
|
|
|
Per-axis UV scale |
|
|
|
Counter-clockwise about the UV origin |
|
|
|
Responds to |
|
|
|
Whether |
Texture sources¶
Every *_map kwarg accepts four forms:
Filesystem / asset URI (str)¶
Material(albedo_map="assets/brick.png")
Material(albedo_map="pkg://my_game.assets/brick.png") # importlib.resources URI
The backend’s TextureManager resolves the path, decodes the image, and
caches by (source, filter).
Embedded image bytes (bytes)¶
with open("brick.png", "rb") as f:
Material(albedo_map=f.read())
Useful when bundling a single-file game export: the bytes ship in the Python source.
NumPy ndarray (in-memory texture)¶
import numpy as np
# 256x256 procedural ramp, RGBA uint8
ramp = np.zeros((256, 256, 4), dtype=np.uint8)
ramp[..., 0] = np.linspace(0, 255, 256, dtype=np.uint8)[None, :]
ramp[..., 3] = 255
Material(albedo_map=ramp)
# 2D height-tinted gradient, float32 in [0, 1]
gradient = np.zeros((1, 256, 4), dtype=np.float32)
gradient[..., 0] = np.linspace(0.1, 1.0, 256) # R rises with height
gradient[..., 3] = 1.0 # opaque
Material(albedo_map=gradient)
The constructor coerces ndarray sources to uint8 at construction:
uint8→ passed through unchanged.float32/float64in[0, 1]→ scaled by 255 touint8.floatoutside[0, 1]→ WARNING logged, clipped, then scaled.Other dtypes →
TypeError.
This guarantees the GPU sees byte-per-channel data on every backend.
Used by¶
Shipped ports relying on Material(albedo_map=ndarray):
Procedural Planets: bakes an HSL height-and-latitude ramp into a 1D texture so the planet shader can sample without a custom
ShaderMaterial.Q1K3: bakes per-room palette ramps for the retro look.
HexGL: bakes a track-side speed-strip texture.
Use this pattern when:
You need procedural or parameterised shading without writing a custom shader.
You don’t want to ship binary asset files in a code-first port.
The texture is small enough that numpy generation is cheaper than disk I/O.
Texture resource (Texture)¶
A simvx.core.Texture wraps any of the three forms above and adds an identity,
a version, and an explicit update path: the model Godot’s
ImageTexture.update() and Unity’s Texture2D.Apply() ship.
from simvx.core import Texture
ramp = Texture(pixels, filter="nearest") # RGBA uint8 (H, W, 4)
mat = Material(albedo_map=ramp)
pixels[:] = recolour(pixels)
ramp.update() # re-uploads in place, same GPU slot
Reach for it when the image changes after it is first shown. A raw ndarray
is cached by its address, so mutating it in place is invisible to the renderer;
a Texture knows it changed. update() may also swap the source outright
(ramp.update(new_pixels)), as long as the replacement is the same size: a
texture’s size is fixed for its lifetime, and a replacement that differs from it
(or that nothing can measure) raises ValueError. Sprites and nine-patches
measure a texture when they resolve it, so a bigger image is a new texture:
build one and assign it over the old, and every consumer re-resolves.
Texture.size answers before any upload, from an ndarray’s shape or the image’s
header. The one case it cannot answer is a container no reader available at
runtime understands, and that is also the one case update() has no size to
hold a replacement to; the backend fills the size in as soon as it decodes the
image.
The GPU slot survives the update, so every material and sprite already drawing
the texture shows the new image without being reassigned. That slot belongs to
the resource alone: Texture("brick.png") uploads its own copy rather than
sharing the one albedo_map="brick.png" already loaded, so update() can never
change an image something else asked for, and the slot goes back to the backend
when the resource is collected. The sampling settings
(filter, colour_space, premultiply_alpha, mipmaps) are fixed at
construction: they select which slot the image lands in, so changing one would be
a different texture rather than an update to this one.
Each of those settings is optional, and leaving one out is not the same as
choosing its usual default: an unset setting follows whatever the consumer asks
for, so a wrapped texture is sampled exactly as the raw source would have been in
that position. normal_map=Texture(pixels) still uploads linear, and
albedo_map=Texture(pixels) still gets its mip chain, because those come from
the map role. State a setting only to override the consumer everywhere the
texture is used, as filter="nearest" does above.
One resource owns one slot, so it carries one sampling. Using the same Texture
as both an albedo_map and a normal_map cannot satisfy both; the first use
wins and the second logs a warning naming the conflict. Give each sampling its
own Texture over the same source.
Materials on the GPU¶
A material is one row of a bindless SSBO the shader indexes per draw, and the renderer hands out those rows by content: two materials with the same colour, PBR parameters, flags and maps share a row. The row belongs to every material using it, and is handed back once none of them is alive, so creating a material per frame costs one row rather than one row per frame:
def on_update(self, dt):
self.cube.material = Material(colour=(1, self.t % 1.0, 0, 1)) # one row, reused
Mutating a material is fine too, and does not disturb any other material it
happened to match: mat.colour = ... moves it onto a row of its own (or onto an
existing row with the new content) the next time the frame is drawn.
A copy is a different material, not another name for the same one. copy.copy
and copy.deepcopy give the copy its own identity, so the common “start from
this one and recolour it” spelling gets its own row and leaves the original
alone:
hurt = copy.deepcopy(self.body_material)
hurt.colour = (1, 0.2, 0.2, 1) # the original still renders its own colour
A row is handed back when the last material using it is collected. Reassigning
node.material releases the old one there and then; removing a node from the
tree does not, because a removed node is still reachable through a reference
cycle, so it is the cyclic collector that frees it. The row comes back on the
next collection, which matters only for a scene that discards thousands of
distinct materials at once.
There is no material budget to plan around. A frame needing more rows than the buffer holds grows the buffer, doubling it, and the frame after it renders every material. One frame is affected: the overflowing frame logs a WARNING and draws with the rows it has, so the materials that did not fit render black in that frame alone. Nothing is truncated silently or permanently.
A handed-back row is reused by the next material rather than shrinking the buffer. Both the buffer’s capacity and the number of rows uploaded each frame stay at the session’s high-water mark, as they do for the transform arena beside it: a scene that once held 20,000 distinct materials keeps uploading 20,000 rows (96 bytes each) for the rest of the session, whether or not they are still alive. Reusing one material across many nodes, rather than minting one per node, is what keeps that mark low.
Web export compatibility¶
The Material data shape is identical across Vulkan and WebGPU, every texture
source included: an albedo_map=ndarray ships its pixels to the browser and is
uploaded like any decoded image, with no extra configuration.
ShaderMaterial exports too. Its GLSL is transpiled to WGSL at export and
rendered through a per-material pipeline, textures and transparent=True
included. A shader that will not transpile, or that declares a binding neither
backend can use, refuses the export, naming the shader and the line. See
Web export.
Inspector visibility¶
Material fields are plain attributes, not Property descriptors, so the generic
property grid does not list them. The editor’s material section shows the colour,
the metallic and roughness sliders, the blend mode, the wireframe and unlit
checkboxes (unlit is not rendered yet: see the table above), and pickers for the
albedo and normal maps. The other fields are set in code.