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

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

Force VK_POLYGON_MODE_LINE

double_sided

bool

False

Disable backface culling

unlit

bool

False

Skip lighting; use raw albedo

albedo_map

str / bytes / ndarray

None

Diffuse texture

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

Scalar multiplier; folds into slot 4

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

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 / 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 / parameterised shading on the web target without a custom shader (or your ShaderMaterial uses 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.