Bake library AO and lift environment textures
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:
1 parent
d8cfddbf89
commit
d872034388
78 files changed
+390
-28
No files matched your search
+33
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -226,12 +226,23 @@ func test_world_loads_the_blender_library_props() -> bool:
|
||||
builder.set_night_mode(false)
|
||||
var dark := builder._lamp_bulb_mats[0].emission_energy_multiplier <= 0.0
|
||||
var e := TestUtils.check(lit and dark, "night mode drives library emissives")
|
||||
var ao_ok := false
|
||||
var detail := root.get_node_or_null("StreetDetail")
|
||||
if detail != null:
|
||||
for child in detail.get_children():
|
||||
if str(child.name).begins_with("Prop_bench"):
|
||||
for mi in (child as Node3D).find_children("*", "MeshInstance3D", true, false):
|
||||
for index in (mi as MeshInstance3D).mesh.get_surface_count():
|
||||
var mat := (mi as MeshInstance3D).get_surface_override_material(index) as StandardMaterial3D
|
||||
if mat != null and mat.ao_enabled and mat.ao_texture != null:
|
||||
ao_ok = true
|
||||
var ao := TestUtils.check(ao_ok, "library prop materials carry the baked AO map")
|
||||
var triangles := builder.library_triangle_count()
|
||||
var f := TestUtils.check(triangles > 0
|
||||
and triangles <= WorldBuilder.LIBRARY_TRIANGLE_BUDGET,
|
||||
"library prop triangles stay inside the budget (%d)" % triangles)
|
||||
root.free()
|
||||
return a and b and c and d and e and f
|
||||
return a and b and c and d and e and f and ao
|
||||
|
||||
func test_world_facade_windows_light_at_night() -> bool:
|
||||
var root := Node3D.new()
|
||||
|
||||
@@ -229,8 +229,14 @@ func test_skinned_outfit_tints_the_material_slots() -> bool:
|
||||
var a := TestUtils.check(found == 5, "five material slots stay separate")
|
||||
var b := TestUtils.check(tinted == found, "every slot carries the outfit tint")
|
||||
var c := TestUtils.check(outfit["shirt"] != Color.WHITE, "seed gives a non-white outfit")
|
||||
var ao_ok := false
|
||||
for index in mi.mesh.get_surface_count():
|
||||
var material := mi.get_surface_override_material(index) as StandardMaterial3D
|
||||
if material != null and material.ao_enabled and material.ao_texture != null:
|
||||
ao_ok = true
|
||||
var d := TestUtils.check(ao_ok, "character slots carry the baked AO map")
|
||||
avatar.free()
|
||||
return a and b and c
|
||||
return a and b and c and d
|
||||
|
||||
func test_forced_fallback_still_builds_the_procedural_rig() -> bool:
|
||||
var profile := NpcProfile.new()
|
||||
|
||||
Reference in new issue
Block a user