Add spatial irradiance and character lighting adapter

This commit is contained in:
chazmm committed 2026-09-08 16:41:42 +01:00
1 parent 8c31384a12
commit 7d44d84f19
15 files changed
+730 -5

No files matched your search

@@ -0,0 +1,55 @@
"""Export authored character shader parameters and default SH coefficients."""
import json
import struct
import sys
from PIL import Image
from pathlib import Path
from .environment import Collections, key_hash
def convert(models, private, converted):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'vendor/utt'))
import mdl_parser
collections = Collections(converted)
result = {'materials': {}}
recipe = json.loads((Path(__file__).resolve().parents[1] / 'default_skater_retail_manifest.json').read_text())
components = {c['slot']: c for c in recipe['components']}
for slot in sorted(models.iterdir()):
if not slot.is_dir():
continue
files = list(slot.glob('*.rx2'))
if len(files) != 1:
raise ValueError('Ambiguous character lighting model: '+slot.name)
model = mdl_parser.parse_rx2(files[0].read_bytes())
shaders = {p.value for p in model.materials if p.kind == 'AttribulatorMaterialName'}
if len(shaders) != 1:
raise ValueError('Ambiguous character shader: '+slot.name)
shader = shaders.pop()
family, key = shader.split('.', 1)
fields, _ = collections.resolve('material_'+family, key)
rows = fields[key_hash('m_params')]['array']['items']
result['materials']['Retail_'+slot.name] = dict(shader=shader,
params=[struct.unpack('>4f', bytes.fromhex(row)) for row in rows])
specular = components[slot.name]['textures'].get('specular')
if specular:
# The native skin shader consumes R, not luminance or roughness.
source = private / 'default_skater/textures/decoded' / (specular+'.png')
destination = private / 'native-character' / (slot.name+'_specular.png')
destination.parent.mkdir(parents=True, exist_ok=True)
with Image.open(source) as image:
image.convert('RGB').getchannel('R').save(destination)
result['materials']['Retail_'+slot.name]['specular'] = 'private/native-character/'+destination.name
fields, _ = collections.resolve('Hash_6A2B5946514B8490', 'freeskate')
result['default_sh'] = [struct.unpack('>3f', bytes.fromhex(row))
for row in fields[key_hash('Hash_F45DFCAB6EC195CD')]['array']['items']]
(private/'character-lighting.json').write_text(json.dumps(result))
if __name__ == '__main__':
import argparse
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--models', type=Path, required=True)
p.add_argument('--private', type=Path, required=True)
p.add_argument('--collections', type=Path, required=True)
a = p.parse_args()
convert(a.models, a.private, json.loads(a.collections.read_text()))
+2
View File
@@ -188,6 +188,8 @@ def install(iso,base,game_exe,report,game_root=None):
report('Building the skater model and rig')
from .character_glb import convert as write_character
write_character(character/'selected/models',private,manifest)
from .character_lighting import convert as write_character_lighting
write_character_lighting(character/'selected/models',private,converted)
game_manifest={'version':1,'character_scene':'private/skater.glb','initial_animation':'R_IDLE_HCOM_000',
'action_graph':'private/stock/data/state/ActionGraph_OnBoard.stategraph',
'motion_graph':'private/stock/data/state/MotionGraph_OnBoard.stategraph'}
+51
View File
@@ -0,0 +1,51 @@
"""Export retail RWOBJECTTYPE_IRRADIANCEDATA (0xEB0024) without resampling."""
import json
import math
import struct
from pathlib import Path
def decode(raw):
if raw[:7] != b'\x89RW4xb2':
raise ValueError('Not an Xbox RX2 resource')
count, table = struct.unpack_from('>I', raw, 32)[0], struct.unpack_from('>I', raw, 48)[0]
groups = []
for i in range(count):
offset, _, size, _, _, kind = struct.unpack_from('>6I', raw, table + i*24)
if kind != 0xEB0024:
continue
n, start = struct.unpack_from('>2I', raw, offset)
if start < 8 or start+n*160 > size or offset+size > len(raw):
raise ValueError('Invalid irradiance record extent')
records = []
for j in range(n):
values = struct.unpack_from('>39fI', raw, offset+start+j*160)
if not all(math.isfinite(x) for x in values[:39]):
raise ValueError('Non-finite irradiance sample')
records.append(struct.pack('<39fI', *values))
if records:
groups.append(records)
return groups
def write(manifest_path, output):
manifest = json.loads(manifest_path.read_text())
groups = []
for asset in manifest['other_presentation_assets']:
groups.extend(decode((manifest_path.parent / asset['rx2']).read_bytes()))
output.parent.mkdir(parents=True, exist_ok=True)
with output.open('wb') as f:
f.write(b'SHP1' + struct.pack('<I', len(groups)))
for records in groups:
f.write(struct.pack('<I', len(records)))
f.write(b''.join(records))
return sum(map(len, groups))
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
print('Exported', write(args.manifest, args.output), 'irradiance samples')
+3
View File
@@ -151,6 +151,9 @@ def write(manifest_path,output,collision,report=lambda _:None, *, render_only=Fa
for tag,data in extensions:
f.write(tag);u(f,1,len(data));stored(f,data)
report('Map written: '+m['map_name'])
if not render_only:
from tools.asset_pipeline.irradiance import write as write_irradiance
write_irradiance(manifest_path, output.with_suffix('.irradiance'))
if __name__=='__main__':
import argparse
+37
View File
@@ -0,0 +1,37 @@
"""RX2 irradiance layout checks without owned-game fixtures."""
import struct
import unittest
from .irradiance import decode
def resource():
data = bytearray(272)
data[:7] = b'\x89RW4xb2'
struct.pack_into('>I', data, 32, 1)
struct.pack_into('>I', data, 48, 64)
struct.pack_into('>6I', data, 64, 96, 0, 176, 0, 0, 0xEB0024)
struct.pack_into('>2I', data, 96, 1, 16)
values = [i / 4 for i in range(39)]
struct.pack_into('>39fI', data, 112, *values, 0xDEDEDEDE)
return data, values
class IrradianceTests(unittest.TestCase):
def test_preserves_coefficients_position_and_link_word(self):
data, values = resource()
groups = decode(data)
self.assertEqual(len(groups), 1)
self.assertEqual(struct.unpack('<39fI', groups[0][0]), (*values, 0xDEDEDEDE))
def test_rejects_records_outside_section(self):
data, _ = resource()
struct.pack_into('>I', data, 96, 2)
with self.assertRaises(ValueError):
decode(data)
def test_rejects_nonfinite_sh(self):
data, _ = resource()
struct.pack_into('>f', data, 112, float('nan'))
with self.assertRaises(ValueError):
decode(data)