Materials and PBR

Acron draws physically based materials with acron.simplepbr, a forward metal-rough PBR pipeline that takes over the scene’s shading the way the auto-shader would. It is part of the acron wheel, together with the packages it works with: acron.gltf (the glTF loader, see glTF Files), acron.blend2bam and acron.hdri. None of them is installed separately – a project that depends on acron has them all, and build_apps packs them into the game like any other module.

On top of the pipeline sit two smaller modules: acron.hdri, which turns an HDR panorama into image-based lighting, and acron.pbr, which changes the pipeline’s shaders where a game needs Godot’s lighting rather than the pipeline’s own. acron.materials puts materials back onto models that lost their textures on export.

The pipeline

simplepbr.init() builds the pipeline for the running ShowBase: it puts its shader on base.render, renders the scene into an offscreen buffer and tonemaps that into the window.

from direct.showbase.ShowBase import ShowBase
from acron import simplepbr


class Game(ShowBase):
    def __init__(self):
        super().__init__()
        self.pipeline = simplepbr.init(
            use_normal_maps=True,
            use_occlusion_maps=True,
            max_lights=8,
        )
        self.loader.load_model('level.glb').reparent_to(self.render)

init() returns the Pipeline. Its options can be passed to init() or set on the pipeline later, which recompiles what they affect; each one can also come from a PRC variable named simplepbr- and the option with hyphens (simplepbr-max-lights 8). The ones a game sets most often:

Option

What it does

max_lights

the number of lights the shader evaluates (default 8)

use_normal_maps

read normal maps (default off; the model needs four-component tangents, as glTF models have)

use_occlusion_maps

read ambient occlusion from the metal-roughness map (default off)

use_emission_maps

read emission maps (default on)

enable_shadows, shadow_bias

shadow maps for directional lights and spotlights that cast them

exposure

brightness before tonemapping, in stops (0 is unchanged)

msaa_samples

multisampling of the scene buffer (default 4)

env_map

an EnvMap (or the path of a cube map) for image-based lighting

sdr_lut, sdr_lut_factor

a colour-grading lookup table applied after tonemapping

enable_fog

exponential fog

The shader expects a model’s textures on the stages glTF uses: base colour (M_modulate), metal-roughness (M_selector), normals (M_normal) and emission (M_emission). Models loaded from glTF, or converted from Blender with blend2bam’s PBR material mode, are set up that way already. Egg files carry no PBR materials.

Lighting from a panorama

An EnvMap holds a cube map, its spherical-harmonic diffuse term and a prefiltered specular chain. acron.hdri makes one from an equirectangular .hdr or .exr panorama, keeping the full HDR range:

from acron import hdri, simplepbr

env_map = hdri.load_env('assets/sky.hdr', face_w=512)
pipeline = simplepbr.init(env_map=env_map)

skybox = simplepbr.utils.make_skybox(env_map.cubemap)
skybox.reparent_to(render)

load_env converts at start-up, which is fine while developing. For a release, convert once with save_env and load the result, which is fast:

# once, as a build step
hdri.save_env('assets/sky.hdr', 'built/sky.env', face_w=512)

# at run time
env_map = simplepbr.EnvPool.ptr().load('sky.env')
simplepbr.init(env_map=env_map)

save_env also writes sky.light.json beside the .env: the direction and colour of the panorama’s brightest light. hdri.load_light reads it back as a DominantLight with h, p and color, which is what a key light needs to agree with the sky:

from acron.core import DirectionalLight

light = hdri.load_light('built/sky.env')
key = render.attach_new_node(DirectionalLight('key'))
key.node().set_color(light.color)
key.set_hpr(light.h, light.p, 0)
render.set_light(key)

acronmanager build can run the conversion for you. The acron wheel registers an equirect2env converter, which turns every .hdr and .exr in the project’s assets into a .env and its light file:

[tool.acron.general]
plugins = ["DefaultPlugins", "equirect2env"]

[[tool.acron.build.streams]]
plugin = "equirect2env"
include_patterns = ["*.hdr", "*.exr"]

[tool.acron.equirect2env]
face_size = 512          # cube map face resolution
prefiltered_size = 64    # base size of the specular chain
prefiltered_samples = 16 # quality against baking time

plugins belongs under [tool.acron.general], not directly under [tool.acron].

Godot’s lighting: acron.pbr

acron.pbr changes four things in the pipeline’s shaders, for a game whose lighting was authored in Godot and has to look the same:

Patch

What it changes

falloff

point and spot lights fall off as Godot’s do, reaching zero at their range, and a spotlight’s cone has Godot’s soft rim

projector

a light can carry a projected texture (Godot’s light_projector, a light cookie)

ambient

a metal takes no flat ambient light

tonemap

Godot’s linear or AgX tonemapper and adjustment_saturation, in place of the pipeline’s filmic curve

The patches go into the shader source before it is compiled, so build the pipeline through acron.pbr, which applies them first, instead of calling simplepbr.init() yourself:

from acron import pbr

pipeline = pbr.init(max_lights=8, use_normal_maps=True)

pbr.init() takes the pipeline’s own options. If something else has to build the pipeline, call pbr.apply_all() before the first simplepbr.init(); it does nothing the second time. Each patch names the exact shader text it replaces, and apply_all() raises – having changed nothing – when that text is not there, so a pipeline that does not match the patches fails at start-up rather than rendering with the wrong lighting.

The patched shaders read their parameters from the scene:

  • Range and cone. A light lit by Godot’s falloff carries its range in its attenuation: set it with light.set_attenuation(pbr.falloff.attenuation_for_range(25.0, 1.0)) (the range, and for a spotlight the exponent of its cone’s edge). A light whose attenuation is left as it was keeps the pipeline’s own falloff.

  • The light cookie. The shader declares a light_projector texture, and every declared input must have a value, so give the scene one even when no light uses it: pbr.projector.set_projector(render, pbr.projector.white()). For a real cookie, pass the texture through pbr.projector.prepare() first, which clamps it to a black border.

  • The tonemapper. pbr.tonemap.Tonemap is one Godot environment’s tonemapper (exposure, agx, white, contrast, saturation); pbr.tonemap.LINEAR is Godot’s default. Its shader_inputs() are the values of the post-process pass’s tm_agx and tm_grade inputs, and its stops is the exposure as the pipeline’s exposure option takes it.

Each law is also there as plain Python (pbr.falloff.falloff, pbr.falloff.cone, pbr.ambient.contribution, pbr.tonemap.agx_curve), so a test can check the arithmetic without a window.

Materials on texture-less models

Some exporters write a model’s material names but not its textures: the model loads with every surface flat grey, find_all_textures() finds nothing and find_all_materials() finds the names. acron.materials puts the materials back. A ParsedMaterial is one material as plain data – the paths of its base colour, normal, ORM (occlusion, roughness, metallic) and emission maps, and the factors that stand in for missing maps – and apply_materials applies an index of them, keyed by the lower-case material name, to every geom of a model:

from acron.materials import ParsedMaterial, apply_materials

index = {
    'crate': ParsedMaterial(
        name='crate',
        albedo_png='/game/art/crate_albedo.png',
        orm_png='/game/art/crate_orm.png',
        normal_png='/game/art/crate_normal.png',
        normal_enabled=True,
    ),
}
model = loader.load_model('props.glb')
applier = apply_materials(model, index)
print(applier.textured, applier.textureless, applier.missed)

The textures go onto simplepbr’s stages, bound to the UV set each geom actually has (glTF names them texcoord.0, texcoord.1 and so on), and each geom gets its own state, so geoms that share a node keep their own materials. The applier reports what it did: textured, textureless, the material names it found no entry for (missed) and the textured materials that landed on geometry without UVs (uvless). Pass one MaterialApplier to several calls to share its texture cache.

acron.gdformat.materials builds such an index from a Godot project’s StandardMaterial3D resources (build_material_index for directories of them, index_for_model for the materials one model binds). Two rules hold whichever way the index is made: build the pipeline with use_normal_maps, use_occlusion_maps and use_emission_maps set as the materials need, and do not also enable the auto-shader. Whether a texture landed is only visible on a real window, so check a screenshot.