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

colour

RGBA (or RGB) in 0-1

(1, 1, 1, 1)

Multiplied by albedo at shade time

metallic

float 0-1

0.0

PBR metallic factor

roughness

float 0-1

0.5

PBR roughness factor

blend

"opaque" / "alpha" / "additive" / "cutoff"

"opaque"

Pipeline derivative selector

alpha_cutoff

float 0-1

0.5

Alpha-test threshold for blend="cutoff" (renders in the opaque pass, no sorting)

wireframe

bool

False

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

double_sided

bool

False

Disable backface culling

unlit

bool

False

Not rendered yet: stored and saved, but neither renderer draws it

albedo_map

str / bytes / ndarray

None

Diffuse texture; also a SubViewport, RenderView or PlanarReflection3D node, whose live feed is sampled

normal_map

str / bytes / ndarray

None

Tangent-space normal map

metallic_roughness_map

str / bytes / ndarray

None

Packed B=metal, G=rough

emissive_map

str / bytes / ndarray

None

RGB emissive

ao_map

str / bytes / ndarray

None

Ambient occlusion

emissive_colour

3- or 4-tuple

None

RGB or RGB+intensity

emissive_strength

float

None

Multiplier on the emissive RGB, stored as the fourth component of emissive_colour

needs_scene_colour

bool

False

Samples the copied opaque scene colour (refraction); only meaningful with blend="alpha"

needs_scene_depth

bool

False

Samples the copied opaque scene depth

uv_offset

(u, v)

(0, 0)

Applied to every map: uv' = rotate(uv * uv_scale) + uv_offset

uv_scale

(u, v)

(1, 1)

Per-axis UV scale

uv_rotation

float radians

0.0

Counter-clockwise about the UV origin

wetness_affected

bool

False

Responds to WorldEnvironment rain: darker albedo, lower roughness, ripples on flat faces

receives_decals

bool

True

Whether Decal3D projectors composite onto the surface

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 / float64 in [0, 1] → scaled by 255 to uint8.

  • float outside [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.