This commit is contained in:
2026-04-27 19:21:50 -07:00
parent 98856b3f54
commit a3d5fa846b
16 changed files with 513 additions and 337 deletions

View File

@ -610,19 +610,30 @@ class LeenkxExporter:
return None return None
def write_bone_matrices(self, scene, action): def write_bone_matrices(self, scene, action):
# profile_time = time.time()
begin_frame, end_frame = int(action.frame_range[0]), int(action.frame_range[1]) begin_frame, end_frame = int(action.frame_range[0]), int(action.frame_range[1])
if len(self.bone_tracks) > 0: if len(self.bone_tracks) > 0:
for i in range(begin_frame, end_frame + 1): hidden_states = {}
scene.frame_set(i) try:
for track in self.bone_tracks: for obj in list(scene.collection.all_objects):
values, pose_bone = track[0], track[1] if obj is not None and obj.type != 'ARMATURE' and not obj.hide_viewport:
parent = pose_bone.parent hidden_states[obj] = False
if parent: obj.hide_viewport = True
values += LeenkxExporter.write_matrix((parent.matrix.inverted_safe() @ pose_bone.matrix))
else: for i in range(begin_frame, end_frame + 1):
values += LeenkxExporter.write_matrix(pose_bone.matrix) scene.frame_set(i)
# print('Bone matrices exported in ' + str(time.time() - profile_time)) for track in self.bone_tracks:
values, pose_bone = track[0], track[1]
parent = pose_bone.parent
if parent:
values += LeenkxExporter.write_matrix((parent.matrix.inverted_safe() @ pose_bone.matrix))
else:
values += LeenkxExporter.write_matrix(pose_bone.matrix)
finally:
for obj, was_hidden in hidden_states.items():
try:
obj.hide_viewport = was_hidden
except ReferenceError:
pass
@staticmethod @staticmethod
def has_baked_material(bobject, materials): def has_baked_material(bobject, materials):
@ -849,23 +860,36 @@ class LeenkxExporter:
out_object['tilesheet_ref'] = bobject.lnx_tilesheet out_object['tilesheet_ref'] = bobject.lnx_tilesheet
out_object['tilesheet_action_ref'] = bobject.lnx_tilesheet_action out_object['tilesheet_action_ref'] = bobject.lnx_tilesheet_action
if len(bobject.vertex_groups) > 0 and bobject.data is not None and len(bobject.data.vertices) > 0:
if len(bobject.vertex_groups) > 0:
out_object['vertex_groups'] = [] out_object['vertex_groups'] = []
_verts = bobject.data.vertices
_num_verts = len(_verts)
_all_co = np.empty(_num_verts * 3, dtype='<f8')
_verts.foreach_get('co', _all_co)
_all_str = _all_co.astype('U32').tolist()
_num_groups = len(bobject.vertex_groups)
_vg_idx = [[] for _ in range(_num_groups)]
for v in _verts:
vi = v.index
for g in v.groups:
gi = g.group
if 0 <= gi < _num_groups:
_vg_idx[gi].append(vi)
for group in bobject.vertex_groups: for group in bobject.vertex_groups:
verts = [] indices = _vg_idx[group.index]
for v in bobject.data.vertices: if len(indices) > 0:
for g in v.groups: _values = []
if g.group == group.index: for vi in indices:
verts.append(str(v.co.x)) base = vi * 3
verts.append(str(v.co.y)) _values.append(_all_str[base])
verts.append(str(v.co.z)) _values.append(_all_str[base + 1])
_values.append(_all_str[base + 2])
out_vertex_groups = { else:
_values = []
out_object['vertex_groups'].append({
'name': group.name, 'name': group.name,
'value': verts 'value': _values
} })
out_object['vertex_groups'].append(out_vertex_groups)
if len(bobject.lnx_camera_list) > 0: if len(bobject.lnx_camera_list) > 0:
out_camera_list = [] out_camera_list = []
@ -1114,13 +1138,27 @@ class LeenkxExporter:
_o.select_set(False) _o.select_set(False)
skelobj.select_set(True) skelobj.select_set(True)
bake_result = bpy.ops.nla.bake( _bake_hidden = {}
frame_start=int(action.frame_range[0]), try:
frame_end=int(action.frame_range[1]), for _obj in list(bpy.context.scene.collection.all_objects):
step=1, if _obj is not None and _obj.type != 'ARMATURE' and not _obj.hide_viewport:
only_selected=False, _bake_hidden[_obj] = False
visual_keying=True _obj.hide_viewport = True
)
bake_result = bpy.ops.nla.bake(
frame_start=int(action.frame_range[0]),
frame_end=int(action.frame_range[1]),
step=1,
only_selected=False,
visual_keying=True
)
finally:
for _obj, _was_hidden in _bake_hidden.items():
try:
_obj.hide_viewport = _was_hidden
except ReferenceError:
pass
action = skelobj.animation_data.action action = skelobj.animation_data.action
skelobj.select_set(False) skelobj.select_set(False)
@ -1225,44 +1263,67 @@ class LeenkxExporter:
else: else:
group_remap.append(-1) group_remap.append(-1)
bone_count_array = np.empty(len(export_mesh.loops), dtype='<i2') num_loops = len(export_mesh.loops)
bone_index_array = np.empty(len(export_mesh.loops) * 4, dtype='<i2') _loop_vidx = np.empty(num_loops, dtype='<i4')
bone_weight_array = np.empty(len(export_mesh.loops) * 4, dtype='<f4') export_mesh.loops.foreach_get('vertex_index', _loop_vidx)
num_verts = len(bobject.data.vertices)
verts = bobject.data.vertices
vert_bone_count = np.zeros(num_verts, dtype='<i2')
vert_bone_index = np.zeros((num_verts, 4), dtype='<i2')
vert_bone_weight = np.zeros((num_verts, 4), dtype='<f4')
_group_remap = group_remap
for vi in range(num_verts):
groups = verts[vi].groups
n = len(groups)
if n == 0:
continue
vertices = bobject.data.vertices
count = 0
for index, l in enumerate(export_mesh.loops):
bone_count = 0
total_weight = 0.0
bone_values = [] bone_values = []
for g in vertices[l.vertex_index].groups: total_weight = 0.0
bone_index = group_remap[g.group] for gi in range(n):
bone_weight = g.weight g = groups[gi]
if bone_index >= 0: #and bone_weight != 0.0: bone_index = _group_remap[g.group]
bone_values.append((bone_weight, bone_index)) if bone_index >= 0:
total_weight += bone_weight bone_values.append((g.weight, bone_index))
bone_count += 1 total_weight += g.weight
bone_count = len(bone_values)
if bone_count == 0:
continue
if bone_count > 4: if bone_count > 4:
bone_count = 4
bone_values.sort(reverse=True) bone_values.sort(reverse=True)
bone_values = bone_values[:4] bone_values = bone_values[:4]
bone_count = 4
bone_count_array[index] = bone_count total_weight = sum(bv[0] for bv in bone_values)
for bv in bone_values:
bone_weight_array[count] = bv[0]
bone_index_array[count] = bv[1]
count += 1
if total_weight not in (0.0, 1.0): if total_weight not in (0.0, 1.0):
normalizer = 1.0 / total_weight normalizer = 1.0 / total_weight
for i in range(bone_count): bone_values = [(bv[0] * normalizer, bv[1]) for bv in bone_values]
bone_weight_array[count - i - 1] *= normalizer
vert_bone_count[vi] = bone_count
for j in range(bone_count):
vert_bone_weight[vi, j] = bone_values[j][0]
vert_bone_index[vi, j] = bone_values[j][1]
bone_count_array = vert_bone_count[_loop_vidx]
loop_bone_weight = vert_bone_weight[_loop_vidx]
loop_bone_index = vert_bone_index[_loop_vidx]
loop_bone_count = bone_count_array
slot_idx = np.arange(4, dtype='<i2')
active_mask = slot_idx[np.newaxis, :] < loop_bone_count[:, np.newaxis]
bone_weight_array = loop_bone_weight[active_mask].astype('<f4')
bone_index_array = loop_bone_index[active_mask].astype('<i2')
bone_index_array = bone_index_array[:count]
bone_weight_array = bone_weight_array[:count]
bone_weight_array *= 32767 bone_weight_array *= 32767
bone_weight_array = np.array(bone_weight_array, dtype='<i2') bone_weight_array = bone_weight_array.astype('<i2')
oskin['bone_count_array'] = bone_count_array oskin['bone_count_array'] = bone_count_array
oskin['bone_index_array'] = bone_index_array oskin['bone_index_array'] = bone_index_array
@ -1291,6 +1352,8 @@ class LeenkxExporter:
shape_key_base = bobject.data.shape_keys.key_blocks[0] shape_key_base = bobject.data.shape_keys.key_blocks[0]
count = 0 count = 0
_base_co = None
_base_nor = None
# Loop through all shape keys # Loop through all shape keys
for shape_key in bobject.data.shape_keys.key_blocks[1:]: for shape_key in bobject.data.shape_keys.key_blocks[1:]:
@ -1299,7 +1362,13 @@ class LeenkxExporter:
# get vertex data from shape key # get vertex data from shape key
if shape_key.mute: if shape_key.mute:
continue continue
vert_data = self.get_vertex_data_from_shape_key(shape_key_base, shape_key) vert_data = self.get_vertex_data_from_shape_key(
shape_key_base, shape_key,
_base_co=_base_co, _base_nor=_base_nor)
# Cache base arrays after first call
if _base_co is None:
_base_co = vert_data['_base_co']
_base_nor = vert_data['_base_nor']
vert_pos.append(vert_data['pos']) vert_pos.append(vert_data['pos'])
vert_nor.append(vert_data['nor']) vert_nor.append(vert_data['nor'])
names.append(shape_key.name) names.append(shape_key.name)
@ -1342,37 +1411,39 @@ class LeenkxExporter:
morph_target['morph_target_ref'] = names morph_target['morph_target_ref'] = names
morph_target['morph_target_defaults'] = default_values morph_target['morph_target_defaults'] = default_values
morph_target['num_morph_targets'] = count morph_target['num_morph_targets'] = count
morph_target['morph_scale'] = max - min morph_target['morph_scale'] = float(max - min)
morph_target['morph_offset'] = min morph_target['morph_offset'] = float(min)
morph_target['morph_img_size'] = img_size morph_target['morph_img_size'] = int(img_size)
morph_target['morph_block_size'] = block_size morph_target['morph_block_size'] = int(block_size)
out_mesh['morph_target'] = morph_target out_mesh['morph_target'] = morph_target
return return
def get_vertex_data_from_shape_key(self, shape_key_base, shape_key_data): def get_vertex_data_from_shape_key(self, shape_key_base, shape_key_data, _base_co=None, _base_nor=None):
base_vert_pos = shape_key_base.data.values() num_verts = len(shape_key_data.data)
base_vert_nor = shape_key_base.normals_split_get()
vert_pos = shape_key_data.data.values()
vert_nor = shape_key_data.normals_split_get()
num_verts = len(vert_pos) sk_co = np.empty(num_verts * 3, dtype='<f4')
shape_key_data.data.foreach_get('co', sk_co)
sk_co = sk_co.reshape(num_verts, 3)
pos = [] if _base_co is None:
nor = [] _base_co = np.empty(num_verts * 3, dtype='<f4')
shape_key_base.data.foreach_get('co', _base_co)
_base_co = _base_co.reshape(num_verts, 3)
# Loop through all vertices pos_delta = sk_co - _base_co
for i in range(num_verts):
# Vertex position relative to base vertex
pos.append(list(vert_pos[i].co - base_vert_pos[i].co))
temp = []
for j in range(3):
# Vertex normal relative to base vertex
temp.append(vert_nor[j + i * 3] - base_vert_nor[j + i * 3])
nor.append(temp)
return {'pos': pos, 'nor': nor} sk_nor_raw = np.array(shape_key_data.normals_split_get(), dtype='<f4')
sk_nor = sk_nor_raw[:num_verts * 3].reshape(num_verts, 3)
if _base_nor is None:
base_nor_raw = np.array(shape_key_base.normals_split_get(), dtype='<f4')
_base_nor = base_nor_raw[:num_verts * 3].reshape(num_verts, 3)
nor_delta = sk_nor - _base_nor
return {'pos': pos_delta, 'nor': nor_delta, '_base_co': _base_co, '_base_nor': _base_nor}
def bake_to_image(self, pos_array, nor_array, pos_max, pos_min, extra_x, img_size, name, output_dir): def bake_to_image(self, pos_array, nor_array, pos_max, pos_min, extra_x, img_size, name, output_dir):
# Scale position data between [0, 1] to bake to image # Scale position data between [0, 1] to bake to image
@ -1387,21 +1458,19 @@ class LeenkxExporter:
def write_output_image(self, data, extra_x, img_size, name, output_dir): def write_output_image(self, data, extra_x, img_size, name, output_dir):
# Pad data with zeros to make up for required number of pixels of 2^n format # Pad data with zeros to make up for required number of pixels of 2^n format
data = np.pad(data, ((0, 0), (0, extra_x), (0, 0)), 'minimum') data = np.pad(data, ((0, 0), (0, int(extra_x)), (0, 0)), 'minimum')
pixel_list = []
for y in range(len(data)): total_pixels = data.shape[0] * data.shape[1]
for x in range(len(data[0])): rgba = np.ones((total_pixels, 4), dtype=np.float32)
# assign RGBA rgba[:, :3] = data.reshape(-1, 3)
pixel_list.append(data[y, x, 0])
pixel_list.append(data[y, x, 1])
pixel_list.append(data[y, x, 2])
pixel_list.append(1.0)
pixel_list = (pixel_list + [0] * (img_size * img_size * 4 - len(pixel_list))) img_total = img_size * img_size
if total_pixels < img_total:
padding = np.zeros((img_total - total_pixels, 4), dtype=np.float32)
rgba = np.concatenate((rgba, padding), axis=0)
image = bpy.data.images.new(name, width = img_size, height = img_size, is_data = True) image = bpy.data.images.new(name, width = img_size, height = img_size, is_data = True)
image.pixels = pixel_list image.pixels = rgba.ravel().tolist()
output_path = os.path.join(output_dir, name + ".png") output_path = os.path.join(output_dir, name + ".png")
image.save_render(output_path, scene= bpy.context.scene) image.save_render(output_path, scene= bpy.context.scene)
bpy.data.images.remove(image) bpy.data.images.remove(image)
@ -1579,30 +1648,28 @@ class LeenkxExporter:
# Scale for packed coords # Scale for packed coords
lay0 = uv_layers[t0map] lay0 = uv_layers[t0map]
maxdim_uvlayer = lay0 maxdim_uvlayer = lay0
for v in lay0.data: _uv0_raw = np.empty(len(lay0.data) * 2, dtype='<f4')
if abs(v.uv[0]) > maxdim: lay0.data.foreach_get('uv', _uv0_raw)
maxdim = abs(v.uv[0]) _uv0_absmax = float(np.abs(_uv0_raw).max()) if len(_uv0_raw) > 0 else 0.0
if abs(v.uv[1]) > maxdim: if _uv0_absmax > maxdim:
maxdim = abs(v.uv[1]) maxdim = _uv0_absmax
if has_tex1: if has_tex1:
lay1 = uv_layers[t1map] lay1 = uv_layers[t1map]
for v in lay1.data: _uv1_raw = np.empty(len(lay1.data) * 2, dtype='<f4')
if abs(v.uv[0]) > maxdim: lay1.data.foreach_get('uv', _uv1_raw)
maxdim = abs(v.uv[0]) _uv1_absmax = float(np.abs(_uv1_raw).max()) if len(_uv1_raw) > 0 else 0.0
maxdim_uvlayer = lay1 if _uv1_absmax > maxdim:
if abs(v.uv[1]) > maxdim: maxdim = _uv1_absmax
maxdim = abs(v.uv[1]) maxdim_uvlayer = lay1
maxdim_uvlayer = lay1
if has_morph_target: if has_morph_target:
morph_data = np.empty(num_verts * 2, dtype='<f4') morph_data = np.empty(num_verts * 2, dtype='<f4')
lay2 = uv_layers[morph_uv_index] lay2 = uv_layers[morph_uv_index]
for v in lay2.data: _uv2_raw = np.empty(len(lay2.data) * 2, dtype='<f4')
if abs(v.uv[0]) > maxdim: lay2.data.foreach_get('uv', _uv2_raw)
maxdim = abs(v.uv[0]) _uv2_absmax = float(np.abs(_uv2_raw).max()) if len(_uv2_raw) > 0 else 0.0
maxdim_uvlayer = lay2 if _uv2_absmax > maxdim:
if abs(v.uv[1]) > maxdim: maxdim = _uv2_absmax
maxdim = abs(v.uv[1]) maxdim_uvlayer = lay2
maxdim_uvlayer = lay2
if maxdim > 1: if maxdim > 1:
o['scale_tex'] = maxdim o['scale_tex'] = maxdim
invscale_tex = (1 / o['scale_tex']) * 32767 invscale_tex = (1 / o['scale_tex']) * 32767
@ -1636,102 +1703,87 @@ class LeenkxExporter:
invscale_pos = (1 / scale_pos) * 32767 invscale_pos = (1 / scale_pos) * 32767
verts = export_mesh.vertices verts = export_mesh.vertices
_all_co = np.empty(len(verts) * 3, dtype='<f4')
verts.foreach_get('co', _all_co)
_all_co = _all_co.reshape(-1, 3)
_loop_vidx = np.empty(num_verts, dtype='<i4')
loops.foreach_get('vertex_index', _loop_vidx)
_loop_co = _all_co[_loop_vidx]
_all_normals = np.empty(num_verts * 3, dtype='<f4')
loops.foreach_get('normal', _all_normals)
_all_normals = _all_normals.reshape(-1, 3)
pdata[0::4] = _loop_co[:, 0]
pdata[1::4] = _loop_co[:, 1]
pdata[2::4] = _loop_co[:, 2]
pdata[3::4] = _all_normals[:, 2] * scale_pos
ndata[0::2] = _all_normals[:, 0]
ndata[1::2] = _all_normals[:, 1]
if has_tex: if has_tex:
lay0 = export_mesh.uv_layers[t0map] lay0 = export_mesh.uv_layers[t0map]
lay0.data.foreach_get('uv', t0data)
t0data[1::2] = 1.0 - t0data[1::2]
if has_tex1: if has_tex1:
lay1 = export_mesh.uv_layers[t1map] lay1 = export_mesh.uv_layers[t1map]
lay1.data.foreach_get('uv', t1data)
t1data[1::2] = 1.0 - t1data[1::2]
if has_tang:
loops.foreach_get('tangent', tangdata)
if has_morph_target: if has_morph_target:
lay2 = export_mesh.uv_layers[morph_uv_index] lay2 = export_mesh.uv_layers[morph_uv_index]
lay2.data.foreach_get('uv', morph_data)
morph_data[1::2] = 1.0 - morph_data[1::2]
if has_col: if has_col:
vcol0 = self.get_nth_vertex_colors(export_mesh, 0).data vcol0 = self.get_nth_vertex_colors(export_mesh, 0).data
_col_raw = np.empty(len(vcol0) * 4, dtype='<f4')
loop: bpy.types.MeshLoop vcol0.foreach_get('color', _col_raw)
for i, loop in enumerate(loops): _col_raw = _col_raw.reshape(-1, 4)
v = verts[loop.vertex_index] cdata[0::3] = _col_raw[:, 0]
co = v.co cdata[1::3] = _col_raw[:, 1]
normal = loop.normal cdata[2::3] = _col_raw[:, 2]
tang = loop.tangent
i4 = i * 4
i2 = i * 2
pdata[i4 ] = co[0]
pdata[i4 + 1] = co[1]
pdata[i4 + 2] = co[2]
pdata[i4 + 3] = normal[2] * scale_pos # Cancel scale
ndata[i2 ] = normal[0]
ndata[i2 + 1] = normal[1]
if has_tex:
uv = lay0.data[loop.index].uv
t0data[i2 ] = uv[0]
t0data[i2 + 1] = 1.0 - uv[1] # Reverse Y
if has_tex1:
uv = lay1.data[loop.index].uv
t1data[i2 ] = uv[0]
t1data[i2 + 1] = 1.0 - uv[1]
if has_tang:
i3 = i * 3
tangdata[i3 ] = tang[0]
tangdata[i3 + 1] = tang[1]
tangdata[i3 + 2] = tang[2]
if has_morph_target:
uv = lay2.data[loop.index].uv
morph_data[i2 ] = uv[0]
morph_data[i2 + 1] = 1.0 - uv[1]
if has_col:
col = vcol0[loop.index].color
i3 = i * 3
cdata[i3 ] = col[0]
cdata[i3 + 1] = col[1]
cdata[i3 + 2] = col[2]
mats = export_mesh.materials mats = export_mesh.materials
poly_map = []
for i in range(max(len(mats), 1)):
poly_map.append([])
for poly in export_mesh.polygons:
poly_map[poly.material_index].append(poly)
o['index_arrays'] = [] o['index_arrays'] = []
# map polygon indices to triangle loops num_tris = len(export_mesh.loop_triangles)
tri_loops = {} if num_tris > 0:
for loop in export_mesh.loop_triangles: _tri_loops = np.empty(num_tris * 3, dtype='<i4')
if loop.polygon_index not in tri_loops: export_mesh.loop_triangles.foreach_get('loops', _tri_loops)
tri_loops[loop.polygon_index] = []
tri_loops[loop.polygon_index].append(loop)
for index, polys in enumerate(poly_map): _tri_polys = np.empty(num_tris, dtype='<i4')
tris = 0 export_mesh.loop_triangles.foreach_get('polygon_index', _tri_polys)
for poly in polys:
tris += poly.loop_total - 2
if tris == 0: # No face assigned
continue
prim = np.empty(tris * 3, dtype='<i4')
v_map = np.empty(tris * 3, dtype='<i4')
i = 0 num_polys = len(export_mesh.polygons)
for poly in polys: _poly_mat_idx = np.empty(num_polys, dtype='<i4')
for loop in tri_loops[poly.index]: export_mesh.polygons.foreach_get('material_index', _poly_mat_idx)
prim[i ] = loops[loop.loops[0]].index
prim[i + 1] = loops[loop.loops[1]].index
prim[i + 2] = loops[loop.loops[2]].index
i += 3
j = 0 _tri_mat_idx = _poly_mat_idx[_tri_polys]
for poly in polys:
for loop in tri_loops[poly.index]:
v_map[j ] = loops[loop.loops[0]].vertex_index
v_map[j + 1] = loops[loop.loops[1]].vertex_index
v_map[j + 2] = loops[loop.loops[2]].vertex_index
j += 3
ia = {'values': prim, 'material': 0, 'vertex_map': v_map} _tri_loops_2d = _tri_loops.reshape(-1, 3)
if len(mats) > 1:
for i in range(len(mats)): # Multi-mat mesh for mat_idx in range(max(len(mats), 1)):
if mats[i] == mats[index]: # Default material for empty slots mask = (_tri_mat_idx == mat_idx)
ia['material'] = i num_mat_tris = int(np.count_nonzero(mask))
break if num_mat_tris == 0:
o['index_arrays'].append(ia) continue
mat_tri_loops = _tri_loops_2d[mask].flatten().astype('<i4')
prim = mat_tri_loops.copy()
v_map = _loop_vidx[mat_tri_loops].astype('<i4')
ia = {'values': prim, 'material': 0, 'vertex_map': v_map}
if len(mats) > 1:
for i in range(len(mats)): # Multi-mat mesh
if mats[i] == mats[mat_idx]: # Default material for empty slots
ia['material'] = i
break
o['index_arrays'].append(ia)
# Pack # Pack
pdata *= invscale_pos pdata *= invscale_pos
@ -1821,7 +1873,6 @@ class LeenkxExporter:
def export_mesh(self, object_ref): def export_mesh(self, object_ref):
"""Exports a single mesh object.""" """Exports a single mesh object."""
# profile_time = time.time()
table = object_ref[1]["objectTable"] table = object_ref[1]["objectTable"]
bobject = table[0] bobject = table[0]
oid = lnx.utils.safestr(object_ref[1]["structName"]) oid = lnx.utils.safestr(object_ref[1]["structName"])
@ -1921,7 +1972,6 @@ class LeenkxExporter:
out_mesh['dynamic_usage'] = bobject.data.lnx_dynamic_usage out_mesh['dynamic_usage'] = bobject.data.lnx_dynamic_usage
self.write_mesh(bobject, fp, out_mesh) self.write_mesh(bobject, fp, out_mesh)
# print('Mesh exported in ' + str(time.time() - profile_time))
if hasattr(bobject, 'evaluated_get'): if hasattr(bobject, 'evaluated_get'):
bobject_eval.to_mesh_clear() bobject_eval.to_mesh_clear()
@ -2680,13 +2730,12 @@ class LeenkxExporter:
} }
# Set viewport camera projection # Set viewport camera projection
if is_viewport_camera: proj, is_persp = self.get_viewport_projection_matrix()
proj, is_persp = self.get_viewport_projection_matrix() if proj is not None:
if proj is not None: if is_persp:
if is_persp: self.extract_projection(out_camera, proj, with_planes=False)
self.extract_projection(out_camera, proj, with_planes=False) else:
else: self.extract_ortho(out_camera, proj)
self.extract_ortho(out_camera, proj)
self.output['camera_datas'].append(out_camera) self.output['camera_datas'].append(out_camera)
out_object = { out_object = {

View File

@ -43,6 +43,8 @@ _last_render_engine = None
def on_depsgraph_update_post(self): def on_depsgraph_update_post(self):
if state.proc_build is not None: if state.proc_build is not None:
return return
if state.is_exporting:
return
# Recache # Recache
depsgraph = bpy.context.evaluated_depsgraph_get() depsgraph = bpy.context.evaluated_depsgraph_get()

View File

@ -1,19 +1,75 @@
import atexit import atexit
import gzip
import http.server import http.server
import io
import os
import socketserver import socketserver
import subprocess import subprocess
haxe_server = None haxe_server = None
_GZIP_MIN_SIZE = 1400
_COMPRESSIBLE = {
'text/html', 'text/css', 'text/javascript', 'text/plain',
'application/javascript', 'application/json', 'application/xml',
'application/wasm', 'image/svg+xml',
}
def run_tcp(port: int, do_log: bool): def run_tcp(port: int, do_log: bool):
class HTTPRequestHandler(http.server.SimpleHTTPRequestHandler): class HTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, format, *args): def log_message(self, format, *args):
if do_log: if do_log:
print(format % args) print(format % args)
def end_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Cache-Control', 'no-cache')
super().end_headers()
def do_GET(self):
ae = self.headers.get('Accept-Encoding', '')
if 'gzip' not in ae:
return super().do_GET()
fpath = self.translate_path(self.path)
if os.path.isdir(fpath):
return super().do_GET()
try:
ctype = self.guess_type(fpath)
if ctype.split(';')[0].strip() not in _COMPRESSIBLE:
return super().do_GET()
fsize = os.path.getsize(fpath)
if fsize < _GZIP_MIN_SIZE:
return super().do_GET()
with open(fpath, 'rb') as f:
raw = f.read()
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode='wb', compresslevel=1) as gz:
gz.write(raw)
compressed = buf.getvalue()
self.send_response(200)
self.send_header('Content-Type', ctype)
self.send_header('Content-Length', str(len(compressed)))
self.send_header('Content-Encoding', 'gzip')
self.end_headers()
self.wfile.write(compressed)
except (FileNotFoundError, PermissionError):
self.send_error(404)
class ThreadedHTTPServer(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
try: try:
http_server = socketserver.TCPServer(("", port), HTTPRequestHandler) http_server = ThreadedHTTPServer(("", port), HTTPRequestHandler)
http_server.serve_forever() http_server.serve_forever()
except: except:
print("Server already running") print("Server already running")

View File

@ -5,7 +5,7 @@ from fractions import Fraction
from gpu_extras.batch import batch_for_shader from gpu_extras.batch import batch_for_shader
if bpy.app.version < (4, 0, 0): if bpy.app.version < (4, 0, 0):
import bgl import bgl
def splitLogLuvAlphaAtlas(imageIn, outDir, quality): def splitLogLuvAlphaAtlas(imageIn, outDir, quality):
pass pass

View File

@ -2,8 +2,7 @@ import bpy, blf, os, gpu
from gpu_extras.batch import batch_for_shader from gpu_extras.batch import batch_for_shader
if bpy.app.version < (4, 0, 0): if bpy.app.version < (4, 0, 0):
import bgl import bgl
class ViewportDraw: class ViewportDraw:
def __init__(self, context, text): def __init__(self, context, text):

View File

@ -9,6 +9,7 @@ import shutil
import stat import stat
from string import Template from string import Template
import subprocess import subprocess
import tempfile
import threading import threading
import time import time
import traceback import traceback
@ -181,6 +182,14 @@ def clear_external_scenes():
appended_scenes = [] appended_scenes = []
def export_data(fp, sdk_path): def export_data(fp, sdk_path):
state.is_exporting = True
try:
export_data_impl(fp, sdk_path)
finally:
state.is_exporting = False
def export_data_impl(fp, sdk_path):
load_external_blends() load_external_blends()
wrd = bpy.data.worlds['Lnx'] wrd = bpy.data.worlds['Lnx']
@ -316,7 +325,11 @@ def export_data(fp, sdk_path):
shaders_path = build_dir + '/compiled/Shaders' shaders_path = build_dir + '/compiled/Shaders'
if not os.path.exists(shaders_path): if not os.path.exists(shaders_path):
os.makedirs(shaders_path) os.makedirs(shaders_path)
write_data.write_compiledglsl(defs + cdefs, make_variants=has_config) inc_changed = write_data.write_compiledglsl(defs + cdefs, make_variants=has_config)
if inc_changed:
for g in glob.glob(shaders_path + '/*.glsl'):
os.utime(g, None)
# Write referenced shader passes # Write referenced shader passes
if not os.path.isfile(build_dir + '/compiled/Shaders/shader_datas.lnx') or state.last_world_defs != wrd.world_defs: if not os.path.isfile(build_dir + '/compiled/Shaders/shader_datas.lnx') or state.last_world_defs != wrd.world_defs:
@ -719,7 +732,7 @@ def build_viewport(viewport_id, width=1920, height=1080):
# _viewport_proc_build checks viewport ,not state.proc_build (which is for play button) # _viewport_proc_build checks viewport ,not state.proc_build (which is for play button)
if _viewport_build_in_progress: if _viewport_build_in_progress:
if _viewport_proc_build is not None and _viewport_proc_build.poll() is None: if _viewport_proc_build is not None and _viewport_proc_build.poll() is None:
# log.info(f'Build in progress: {viewport_id} - launching when ready') log.info(f'Build in progress, viewport {viewport_id} will launch when ready')
return return
else: else:
log.info(f'Resetting stale viewport build state') log.info(f'Resetting stale viewport build state')
@ -829,9 +842,11 @@ def play_viewport(viewport_id, width=1920, height=1080):
global _viewport_build_in_progress, _viewport_pending_launches, _viewport_proc_build global _viewport_build_in_progress, _viewport_pending_launches, _viewport_proc_build
if not viewport_id: if not viewport_id:
log.error('No viewport_id: play_viewport requires an id')
return return
if 'Lnx' not in bpy.data.worlds: if 'Lnx' not in bpy.data.worlds:
log.error('No Lnx world found - cannot start viewport server')
return return
wrd = bpy.data.worlds['Lnx'] wrd = bpy.data.worlds['Lnx']
@ -982,16 +997,8 @@ def build_success():
if wrd.lnx_runtime == 'Browser': if wrd.lnx_runtime == 'Browser':
os.chdir(lnx.utils.get_fp()) os.chdir(lnx.utils.get_fp())
prefs = lnx.utils.get_lnx_preferences() prefs = lnx.utils.get_lnx_preferences()
host = 'localhost'
t = threading.Thread(name='localserver',
target=lnx.lib.server.run_tcp,
args=(prefs.html5_server_port,
prefs.html5_server_log),
daemon=True)
t.start()
build_dir = lnx.utils.build_dir() build_dir = lnx.utils.build_dir()
path = '{}/debug/html5/'.format(build_dir) path = '{}/debug/html5/'.format(build_dir)
url = 'http://{}:{}/{}'.format(host, prefs.html5_server_port, path)
browser = webbrowser.get() browser = webbrowser.get()
browsername = None browsername = None
if hasattr(browser, "name"): if hasattr(browser, "name"):
@ -1004,6 +1011,14 @@ def build_success():
if len(envcmd) == 0: if len(envcmd) == 0:
log.warn(f"Your {envvar} environment variable is set to an empty string") log.warn(f"Your {envvar} environment variable is set to an empty string")
else: else:
host = 'localhost'
t = threading.Thread(name='localserver',
target=lnx.lib.server.run_tcp,
args=(prefs.html5_server_port,
prefs.html5_server_log),
daemon=True)
t.start()
url = 'http://{}:{}/{}'.format(host, prefs.html5_server_port, path)
tplstr = Template(envcmd).safe_substitute({ tplstr = Template(envcmd).safe_substitute({
'host': host, 'host': host,
'port': prefs.html5_server_port, 'port': prefs.html5_server_port,
@ -1016,10 +1031,27 @@ def build_success():
}) })
cmd = re.split(' +', tplstr) cmd = re.split(' +', tplstr)
if len(cmd) == 0: if len(cmd) == 0:
# try file:// protocol with a Chromium-based browser
fast = browsername if browsername and any(b in browsername.lower() for b in ('chrome', 'chromium', 'edge', 'msedge')) else lnx.utils.find_browser()
if fast is not None:
file_url = 'file:///' + os.path.abspath(path + 'index.html').replace('\\', '/')
subprocess.Popen([fast, '--allow-file-access-from-files', '--no-first-run',
'--user-data-dir=' + os.path.join(tempfile.gettempdir(), 'leenkx_browser'),
file_url])
return
host = 'localhost'
t = threading.Thread(name='localserver',
target=lnx.lib.server.run_tcp,
args=(prefs.html5_server_port,
prefs.html5_server_log),
daemon=True)
t.start()
url = 'http://{}:{}/{}'.format(host, prefs.html5_server_port, path)
if browsername in (None, '', 'default'): if browsername in (None, '', 'default'):
webbrowser.open(url) webbrowser.open(url)
return return
cmd = [browsername, url] else:
cmd = [browsername, url]
elif wrd.lnx_runtime == 'Krom': elif wrd.lnx_runtime == 'Krom':
if wrd.lnx_live_patch: if wrd.lnx_live_patch:
live_patch.start() live_patch.start()

View File

@ -16,6 +16,7 @@ if not lnx.is_reload(__name__):
proc_publish_build = None proc_publish_build = None
mod_scripts = [] mod_scripts = []
is_export = False is_export = False
is_exporting = False
is_play = False is_play = False
is_publish = False is_publish = False
is_viewport = False is_viewport = False

View File

@ -310,7 +310,6 @@ def parse_tex_noise(node: bpy.types.ShaderNodeTexNoise, out_socket: bpy.types.No
if bpy.app.version < (5, 0, 0): if bpy.app.version < (5, 0, 0):
def parse_tex_pointdensity(node: bpy.types.ShaderNodeTexPointDensity, out_socket: bpy.types.NodeSocket, state: ParserState) -> Union[floatstr, vec3str]: def parse_tex_pointdensity(node: bpy.types.ShaderNodeTexPointDensity, out_socket: bpy.types.NodeSocket, state: ParserState) -> Union[floatstr, vec3str]:
# Pass through # Pass through
# Color # Color
if out_socket == node.outputs[0]: if out_socket == node.outputs[0]:
return c.to_vec3([0.0, 0.0, 0.0]) return c.to_vec3([0.0, 0.0, 0.0])

View File

@ -166,18 +166,14 @@ def material_needs_sss(material: Material) -> bool:
if sss_node is not None and sss_node.outputs[0].is_linked: if sss_node is not None and sss_node.outputs[0].is_linked:
return True return True
for sss_node in lnx.node_utils.iter_nodes_by_type(material.node_tree, 'BSDF_PRINCIPLED'):
if sss_node is not None and sss_node.outputs[0].is_linked and (sss_node.inputs[1].is_linked or sss_node.inputs[1].default_value != 0.0):
return True
for sss_node in mat_utils.iter_nodes_leenkxpbr(material.node_tree):
if sss_node is not None and sss_node.outputs[0].is_linked and (sss_node.inputs[8].is_linked or sss_node.inputs[8].default_value != 0.0):
return True
for principled_node in lnx.node_utils.iter_nodes_by_type(material.node_tree, 'BSDF_PRINCIPLED'): for principled_node in lnx.node_utils.iter_nodes_by_type(material.node_tree, 'BSDF_PRINCIPLED'):
if principled_node is not None and principled_node.outputs[0].is_linked: if principled_node is not None and principled_node.outputs[0].is_linked:
sss_input = principled_node.inputs.get('Subsurface Weight') or principled_node.inputs.get('Subsurface') sss_input = principled_node.inputs.get('Subsurface Weight') or principled_node.inputs.get('Subsurface')
if sss_input is not None and (sss_input.is_linked or sss_input.default_value > 0.0): if sss_input is not None and (sss_input.is_linked or sss_input.default_value > 0.0):
return True return True
for sss_node in mat_utils.iter_nodes_leenkxpbr(material.node_tree):
if sss_node is not None and sss_node.outputs[0].is_linked and (sss_node.inputs[8].is_linked or sss_node.inputs[8].default_value != 0.0):
return True
return False return False

View File

@ -94,15 +94,16 @@ def make(context_id, rpasses, shadowmap=False, shadowmap_transparent=False):
make_attrib.write_norpos(con_depth, vert) make_attrib.write_norpos(con_depth, vert)
frag.write_attrib('vec3 n = normalize(wnormal);') frag.write_attrib('vec3 n = normalize(wnormal);')
cycles.parse(mat_state.nodes, con_depth, vert, frag, geom, tesc, tese, basecol_only=True, parse_opacity=True) cycles.parse(mat_state.nodes, con_depth, vert, frag, geom, tesc, tese, basecol_only=True, parse_opacity=True)
elif parse_opacity: else:
frag.write('float opacity;') if parse_opacity:
frag.write('float ior;') frag.write('float opacity;')
frag.write('float ior;')
if(con_depth).is_elem('morph'): if(con_depth).is_elem('morph'):
make_morph_target.morph_pos(vert) make_morph_target.morph_pos(vert)
if con_depth.is_elem('bone'): if con_depth.is_elem('bone'):
make_skin.skin_pos(vert) make_skin.skin_pos(vert)
if (not is_disp and parse_custom_particle): if (not is_disp and parse_custom_particle):
cycles.parse(mat_state.nodes, con_depth, vert, frag, geom, tesc, tese, parse_surface=False, parse_opacity=parse_opacity) cycles.parse(mat_state.nodes, con_depth, vert, frag, geom, tesc, tese, parse_surface=False, parse_opacity=parse_opacity)

View File

@ -133,6 +133,7 @@ def build(material: Material, mat_users: Dict[Material, List[Object]], mat_lnxus
shader_data_path = lnx.utils.get_fp_build() + '/compiled/Shaders/' + shader_data_name + '.lnx' shader_data_path = lnx.utils.get_fp_build() + '/compiled/Shaders/' + shader_data_name + '.lnx'
assets.add_shader_data(shader_data_path) assets.add_shader_data(shader_data_path)
# Store SSS state in the return tuple so it's preserved per-material
needs_sss_result = mat_state.needs_sss needs_sss_result = mat_state.needs_sss
return rpasses, mat_state.data, shader_data_name, bind_constants, bind_textures, needs_sss_result return rpasses, mat_state.data, shader_data_name, bind_constants, bind_textures, needs_sss_result
@ -172,10 +173,9 @@ def write_shader(rel_path: str, shader: Shader, ext: str, rpass: str, matname: s
shader_path = lnx.utils.get_fp() + '/' + rel_path + '/' + shader_file shader_path = lnx.utils.get_fp() + '/' + rel_path + '/' + shader_file
assets.add_shader(shader_path) assets.add_shader(shader_path)
if not os.path.isfile(shader_path) or not keep_cache: if not os.path.isfile(shader_path) or not keep_cache:
with open(shader_path, 'w') as f: written = lnx.utils.write_if_changed(shader_path, shader.get())
f.write(shader.get())
if shader.noprocessing: if written and shader.noprocessing:
cwd = os.getcwd() cwd = os.getcwd()
os.chdir(lnx.utils.get_fp() + '/' + rel_path) os.chdir(lnx.utils.get_fp() + '/' + rel_path)
hlslbin_path = lnx.utils.get_sdk_path() + '/lib/leenkx_tools/hlslbin/hlslbin.exe' hlslbin_path = lnx.utils.get_sdk_path() + '/lib/leenkx_tools/hlslbin/hlslbin.exe'

View File

@ -424,7 +424,6 @@ class KromViewportEngine(bpy.types.RenderEngine):
self._shm_buffer = (ctypes.c_ubyte * actual_size).from_address(ptr) self._shm_buffer = (ctypes.c_ubyte * actual_size).from_address(ptr)
self._initialized = True self._initialized = True
# print(f"Connected: {shmem_name}")
return True return True
except Exception as e: except Exception as e:
print(f"Failed to open shared memory: {e}") print(f"Failed to open shared memory: {e}")
@ -451,7 +450,6 @@ class KromViewportEngine(bpy.types.RenderEngine):
self._shm_file = open(shm_path, 'r+b') self._shm_file = open(shm_path, 'r+b')
self._shm = mmap.mmap(self._shm_file.fileno(), max_size, mmap.MAP_SHARED, mmap.PROT_READ | mmap.PROT_WRITE) self._shm = mmap.mmap(self._shm_file.fileno(), max_size, mmap.MAP_SHARED, mmap.PROT_READ | mmap.PROT_WRITE)
self._initialized = True self._initialized = True
# print(f"Connected: {shm_path}")
return True return True
except Exception as e: except Exception as e:
print(f"Failed to open /dev/shm: {e}") print(f"Failed to open /dev/shm: {e}")
@ -856,7 +854,7 @@ class KromViewportEngine(bpy.types.RenderEngine):
was_initialized = self._initialized was_initialized = self._initialized
if not self._init_shared_memory(context): if not self._init_shared_memory(context):
if not self._krom_launch_attempted: if not self._krom_launch_attempted:
# print(f"Shared memory not found, launching Krom for viewport {self._viewport_id}") print(f"Shared memory not found, launching Krom for viewport {self._viewport_id}")
self._launch_krom_process(context) self._launch_krom_process(context)
self._draw_placeholder(context) self._draw_placeholder(context)
return return

View File

@ -52,6 +52,46 @@ class WorkingDir:
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
os.chdir(self.prev_cwd) os.chdir(self.prev_cwd)
def write_if_changed(filepath, content):
"""Write content to filepath only if it differs from existing file preserving mtime when unchanged"""
if os.path.isfile(filepath):
with open(filepath, 'r') as f:
if f.read() == content:
return False
with open(filepath, 'w') as f:
f.write(content)
return True
def find_browser():
"""Find a browser that supports file:// with --allow-file-access-from-files - Chrome/Chromium/Edge"""
osn = get_os()
if osn == 'win':
candidates = []
for p in (os.environ.get('PROGRAMFILES', ''), os.environ.get('PROGRAMFILES(X86)', ''), os.environ.get('LOCALAPPDATA', '')):
if p:
candidates.append(os.path.join(p, 'Google', 'Chrome', 'Application', 'chrome.exe'))
candidates.append(os.path.join(p, 'Google', 'Chrome', 'chrome.exe'))
candidates.append(os.path.join(p, 'Google', 'Chromium', 'chrome.exe'))
candidates.append(os.path.join(p, 'Chromium', 'Application', 'chrome.exe'))
for p in (os.environ.get('PROGRAMFILES(X86)', ''), os.environ.get('PROGRAMFILES', '')):
if p:
candidates.append(os.path.join(p, 'Microsoft', 'Edge', 'Application', 'msedge.exe'))
for c in candidates:
if os.path.isfile(c):
return c
elif osn == 'mac':
for app in ('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'):
if os.path.isfile(app):
return app
else:
for name in ('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge'):
found = shutil.which(name)
if found:
return found
return None
def write_lnx(filepath, output): def write_lnx(filepath, output):
if filepath.endswith('.lz4'): if filepath.endswith('.lz4'):
with open(filepath, 'wb') as f: with open(filepath, 'wb') as f:
@ -279,7 +319,7 @@ def get_khamake_threads() -> int:
addon_prefs = get_lnx_preferences() addon_prefs = get_lnx_preferences()
if hasattr(addon_prefs, 'khamake_threads_use_auto') and addon_prefs.khamake_threads_use_auto: if hasattr(addon_prefs, 'khamake_threads_use_auto') and addon_prefs.khamake_threads_use_auto:
return -1 return -1
return 1 if not hasattr(addon_prefs, 'khamake_threads') else addon_prefs.khamake_threads return -1 if not hasattr(addon_prefs, 'khamake_threads') else addon_prefs.khamake_threads
def get_compilation_server(): def get_compilation_server():
addon_prefs = get_lnx_preferences() addon_prefs = get_lnx_preferences()

View File

@ -1,4 +1,5 @@
import glob import glob
import io
import json import json
import os import os
import shutil import shutil
@ -623,7 +624,8 @@ def write_compiledglsl(defs, make_variants):
rpdat = lnx.utils.get_rp() rpdat = lnx.utils.get_rp()
wrd = bpy.data.worlds['Lnx'] wrd = bpy.data.worlds['Lnx']
shadowmap_size = lnx.utils.get_cascade_size(rpdat) if rpdat.rp_shadows else 0 shadowmap_size = lnx.utils.get_cascade_size(rpdat) if rpdat.rp_shadows else 0
with open(lnx.utils.build_dir() + '/compiled/Shaders/compiled.inc', 'w') as f: inc_path = lnx.utils.build_dir() + '/compiled/Shaders/compiled.inc'
with io.StringIO() as f:
f.write( f.write(
"""#ifndef _COMPILED_GLSL_ """#ifndef _COMPILED_GLSL_
#define _COMPILED_GLSL_ #define _COMPILED_GLSL_
@ -883,6 +885,7 @@ const float clusterNear = 3.0;
f.write("""#endif // _COMPILED_GLSL_ f.write("""#endif // _COMPILED_GLSL_
""") """)
return lnx.utils.write_if_changed(inc_path, f.getvalue())
def write_traithx(class_path): def write_traithx(class_path):
wrd = bpy.data.worlds['Lnx'] wrd = bpy.data.worlds['Lnx']