Materials and PBR¶
This page describes part of the Python framework that comes in the
acron wheel. It has no C++ counterpart: a C++ game sets up its own
shaders (see Shaders).
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 |
|---|---|
|
the number of lights the shader evaluates (default 8) |
|
read normal maps (default off; the model needs four-component tangents, as glTF models have) |
|
read ambient occlusion from the metal-roughness map (default off) |
|
read emission maps (default on) |
|
shadow maps for directional lights and spotlights that cast them |
|
brightness before tonemapping, in stops (0 is unchanged) |
|
multisampling of the scene buffer (default 4) |
|
an |
|
a colour-grading lookup table applied after tonemapping |
|
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 |
|---|---|
|
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 |
|
a light can carry a projected texture (Godot’s |
|
a metal takes no flat ambient light |
|
Godot’s linear or AgX tonemapper and |
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_projectortexture, 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 throughpbr.projector.prepare()first, which clamps it to a black border.The tonemapper.
pbr.tonemap.Tonemapis one Godot environment’s tonemapper (exposure,agx,white,contrast,saturation);pbr.tonemap.LINEARis Godot’s default. Itsshader_inputs()are the values of the post-process pass’stm_agxandtm_gradeinputs, and itsstopsis the exposure as the pipeline’sexposureoption 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.