Bake library AO and lift environment textures
Full build / Full desktop build + optional release (push) Successful in 3m43s
Full build / Import + headless tests (push) Successful in 29s

Phase 4 of the v0.6.0 graphics plan:

- tools/bake_ao.py bakes 256² AO maps for every Blender character/gear/prop
  (Cycles, existing UVs) next to each GLB; Materials.apply_ao() wires the red
  channel into character slots, gear, and props. Compatibility has no SSAO.
- Regenerated brick/concrete/asphalt at 1024² with OpenRouter, derived tangent
  normal maps via tools/make_normal_maps.py, and wired them through
  SurfaceMaterials.textured() without changing UV scales.
- Evaluated Forward+: boots on desktop Vulkan, but the renderer is project-wide
  and Web must stay GL Compatibility; v0.6.0 keeps Compatibility (decision in
  MEMORY/plan). A desktop-only override is deferred.
- Recaptured screenshots; compare 11 good / 0 bad. Fixed marquee text alignment.
- Tests: baked AO maps exist for all 46 assets; prop/character AO material
  checks; headless 222/222, Python 69/69, all smokes pass.
- Version 0.6.0; ADR-0013 accepted; README/architecture/blender-assets/plan and
  TODO/DONE updated.
This commit is contained in:
zwitschi committed 2026-11-01 15:07:27 +01:00
1 parent d8cfddbf89
commit d872034388
78 files changed
+390 -28

No files matched your search

+33 -4
View File
@@ -13,6 +13,9 @@ const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
const T_BRICK_NORMAL := preload("res://assets/textures/ai/game_ready/brick_facade_normal.png")
const T_CONCRETE_NORMAL := preload("res://assets/textures/ai/game_ready/concrete_normal.png")
const T_ASPHALT_NORMAL := preload("res://assets/textures/ai/game_ready/asphalt_normal.png")
## Roughness / metallic pairs per surface role. Exposed so tests and tooling can
## assert the profiles instead of hard-coding numbers twice.
@@ -40,14 +43,40 @@ static func npc_slot(source: Material, tint: Color) -> StandardMaterial3D:
material.albedo_color = tint
return material
## Wire a baked AO map (red channel) into every surface of an imported mesh.
## Compatibility has no SSAO, so the library assets carry their own AO.
static func apply_ao(mesh_instance: MeshInstance3D, texture: Texture2D) -> void:
if mesh_instance == null or mesh_instance.mesh == null or texture == null:
return
for index in mesh_instance.mesh.get_surface_count():
var base := mesh_instance.get_surface_override_material(index)
if base == null:
base = mesh_instance.mesh.surface_get_material(index)
var material := base.duplicate() as StandardMaterial3D
if material == null:
continue
material.ao_enabled = true
material.ao_texture = texture
material.ao_texture_channel = BaseMaterial3D.TEXTURE_CHANNEL_RED
mesh_instance.set_surface_override_material(index, material)
static func load_ao(path: String) -> Texture2D:
if not ResourceLoader.exists(path):
return null
return ResourceLoader.load(path) as Texture2D
static func profile(role: String) -> Dictionary:
return PROFILES.get(role, PROFILES["concrete"])
## Base textured surface with a named profile.
static func textured(texture: Texture2D, tint: Color, uv_scale: Vector2,
role: String = "concrete") -> StandardMaterial3D:
role: String = "concrete", normal: Texture2D = null) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_texture = texture
if normal != null:
mat.normal_enabled = true
mat.normal_texture = normal
mat.normal_scale = 0.8
mat.albedo_color = tint
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
@@ -56,13 +85,13 @@ static func textured(texture: Texture2D, tint: Color, uv_scale: Vector2,
return mat
static func brick(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(10, 5)) -> StandardMaterial3D:
return textured(T_BRICK, tint, uv_scale, "brick")
return textured(T_BRICK, tint, uv_scale, "brick", T_BRICK_NORMAL)
static func concrete(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(9, 2.4)) -> StandardMaterial3D:
return textured(T_CONCRETE, tint, uv_scale, "concrete")
return textured(T_CONCRETE, tint, uv_scale, "concrete", T_CONCRETE_NORMAL)
static func asphalt(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(30, 7)) -> StandardMaterial3D:
var mat := textured(T_ASPHALT, tint, uv_scale, "asphalt")
var mat := textured(T_ASPHALT, tint, uv_scale, "asphalt", T_ASPHALT_NORMAL)
# Damp asphalt at night picks up the street lamps and the marquee.
mat.metallic_specular = 0.6
return mat
+11 -2
View File
@@ -453,6 +453,8 @@ func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color,
"hair": outfit["hair"],
"accessory": Color(0.10, 0.11, 0.14),
}
var ao := Materials.load_ao(
BLENDER_NPC_DIR + character_model + "_ao.png")
for index in mesh.get_surface_count():
var source := mesh.surface_get_material(index)
if source == null:
@@ -462,8 +464,12 @@ func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color,
# (hivis, reflective, leather, steel, ...) keep their authored colour.
if not tints.has(slot):
continue
_skinned_mesh.set_surface_override_material(index,
Materials.npc_slot(source, tints[slot]))
var material := Materials.npc_slot(source, tints[slot])
if ao != null:
material.ao_enabled = true
material.ao_texture = ao
material.ao_texture_channel = BaseMaterial3D.TEXTURE_CHANNEL_RED
_skinned_mesh.set_surface_override_material(index, material)
func _bind_bones() -> void:
_bone_indices.clear()
@@ -726,6 +732,9 @@ func _attach_gear(gear_name: String) -> bool:
node.free()
return false
_detail_nodes.append(node)
var ao := Materials.load_ao(BLENDER_GEAR_DIR + gear_name + "_ao.png")
for child in node.find_children("*", "MeshInstance3D", true, false):
Materials.apply_ao(child as MeshInstance3D, ao)
return true
## Character-specific library gear (firefighter helmet/SCBA, dress bag, vest
+15 -2
View File
@@ -125,6 +125,7 @@ func _build_building() -> void:
door_model.position = Vector3(0, 0.0, -3.84)
building.add_child(door_model)
library_nodes.append(door_model)
_apply_library_ao(door_model, "double_door")
for material in _emissive_materials(door_model):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
@@ -287,6 +288,7 @@ func _build_marquee() -> void:
library.position = Vector3(0, 5.62, -3.9)
root.add_child(library)
library_nodes.append(library)
_apply_library_ao(library, "marquee")
for material in _emissive_materials(library):
if not _marquee_emissive.has(material):
_marquee_emissive.append(material)
@@ -304,7 +306,7 @@ func _build_marquee() -> void:
marquee_label = Label3D.new()
marquee_label.name = "MarqueeLabel"
marquee_label.text = "GIG LINE"
marquee_label.position = Vector3(0, 6.45, -2.12)
marquee_label.position = Vector3(0, 6.36, -1.9)
marquee_label.modulate = Color(1, 0.6, 0.9)
marquee_label.font_size = 160
marquee_label.pixel_size = 0.004
@@ -326,6 +328,7 @@ func _build_library_dressing() -> void:
rail.rotation_degrees.y = 90.0
root.add_child(rail)
library_nodes.append(rail)
_apply_library_ao(rail, "handrail")
if ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"):
var sedan := _add_prop_model(root, "sedan", Vector3(6.0, 0.1, 17.0), 180.0)
sedan.name = "ParkedSedan"
@@ -343,6 +346,7 @@ func _build_library_dressing() -> void:
lantern.position = Vector3(side, 3.3, -3.78)
root.add_child(lantern)
library_nodes.append(lantern)
_apply_library_ao(lantern, "wall_lantern")
for material in _emissive_materials(lantern):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
@@ -407,6 +411,14 @@ func library_triangle_count() -> int:
total += vertices.size() / 3
return total
## Apply the baked AO map that sits next to a library prop GLB.
func _apply_library_ao(model: Node3D, prop: String) -> void:
var texture := Materials.load_ao(BLENDER_PROP_DIR + prop + "_ao.png")
if texture == null:
return
for node in model.find_children("*", "MeshInstance3D", true, false):
Materials.apply_ao(node as MeshInstance3D, texture)
## Register a library prop's emissive surfaces so night mode can switch them on.
func _collect_emissive(holder: Node3D) -> void:
for node in holder.find_children("*", "MeshInstance3D", true, false):
@@ -544,7 +556,8 @@ func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
loaded_models.append(prop)
if ResourceLoader.exists(BLENDER_PROP_DIR + prop + ".glb"):
library_props.append(prop)
library_nodes.append(holder)
library_nodes.append(holder)
_apply_library_ao(model, prop)
else:
var fallback := _fallback_prop(prop)
holder.add_child(fallback)