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, flags (wireframe, double-sided, unlit), 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 |
|
|
|
Force |
|
|
|
Disable backface culling |
|
|
|
Skip lighting; use raw albedo |
|
|
|
Diffuse texture |
|
|
|
Tangent-space normal map |
|
|
|
Packed B=metal, G=rough |
|
|
|
RGB emissive |
|
|
|
Ambient occlusion |
|
3- or 4-tuple |
|
RGB or RGB+intensity |
|
|
|
Scalar multiplier; folds into slot 4 |
|
|
|
Applied to every map: |
|
|
|
Per-axis UV scale |
|
|
|
Counter-clockwise about the UV origin |
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, unlit=True)
# 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 / parameterised shading on the web target without a custom shader (or your
ShaderMaterialuses separated textures / transparency, which fall back on web: see below).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.
albedo_map=ndarray is fully supported on web: the numpy buffer ships
through the resource channel as a KIND_TEXTURE upload. No additional
configuration required.
ShaderMaterial is partly web-compatible: opaque, UBO-only shaders are
transpiled GLSL→WGSL at export (build-time naga) and render through a
per-material pipeline. Shaders using separated textures (group(2)
binding 1+) or transparency warn and fall back to the underlying
Material on web; export with --strict to fail instead of degrading.
Inspector visibility¶
All Material constructor arguments are stored as plain Python attributes
on the instance (no Property descriptors yet). The editor inspector
renders the colour swatch and PBR sliders via a custom material widget;
texture-map fields are read-only and show the source URI / “ndarray
({H}x{W}, dtype)” summary.