diff --git a/src/core/data_formats/parsing/ninja/njcm.ts b/src/core/data_formats/parsing/ninja/njcm.ts index 058197ba..3e557a26 100644 --- a/src/core/data_formats/parsing/ninja/njcm.ts +++ b/src/core/data_formats/parsing/ninja/njcm.ts @@ -39,6 +39,8 @@ export type NjcmTriangleStrip = { has_tex_coords: boolean; has_normal: boolean; texture_id?: number; + src_alpha?: number; + dst_alpha?: number; vertices: NjcmMeshVertex[]; }; @@ -89,6 +91,8 @@ type NjcmNullChunk = { type NjcmBitsChunk = { type: NjcmChunkType.Bits; + src_alpha: number; + dst_alpha: number; }; type NjcmCachePolygonListChunk = { @@ -116,6 +120,11 @@ type NjcmTinyChunk = { type NjcmMaterialChunk = { type: NjcmChunkType.Material; + src_alpha: number; + dst_alpha: number; + diffuse?: NjcmArgb; + ambient?: NjcmArgb; + specular?: NjcmErgb; }; type NjcmVertexChunk = { @@ -145,6 +154,26 @@ type NjcmChunkVertex = { calc_continue: boolean; }; +/** + * Channels are in range [0, 1]. + */ +type NjcmArgb = { + a: number; + r: number; + g: number; + b: number; +}; + +/** + * Channels are not normalized. + */ +type NjcmErgb = { + e: number; + r: number; + g: number; + b: number; +}; + export function parse_njcm_model(cursor: Cursor, cached_chunk_offsets: number[]): NjcmModel { const vlist_offset = cursor.u32(); // Vertex list const plist_offset = cursor.u32(); // Triangle strip index list @@ -175,16 +204,34 @@ export function parse_njcm_model(cursor: Cursor, cached_chunk_offsets: number[]) cursor.seek_start(plist_offset); let texture_id: number | undefined = undefined; + let src_alpha: number | undefined = undefined; + let dst_alpha: number | undefined = undefined; for (const chunk of parse_chunks(cursor, cached_chunk_offsets, false)) { - if (chunk.type === NjcmChunkType.Tiny) { - texture_id = chunk.texture_id; - } else if (chunk.type === NjcmChunkType.Strip) { - for (const strip of chunk.triangle_strips) { - strip.texture_id = texture_id; - } + switch (chunk.type) { + case NjcmChunkType.Bits: + src_alpha = chunk.src_alpha; + dst_alpha = chunk.dst_alpha; + break; - meshes.push(...chunk.triangle_strips); + case NjcmChunkType.Tiny: + texture_id = chunk.texture_id; + break; + + case NjcmChunkType.Material: + src_alpha = chunk.src_alpha; + dst_alpha = chunk.dst_alpha; + break; + + case NjcmChunkType.Strip: + for (const strip of chunk.triangle_strips) { + strip.texture_id = texture_id; + strip.src_alpha = src_alpha; + strip.dst_alpha = dst_alpha; + } + + meshes.push(...chunk.triangle_strips); + break; } } } @@ -222,6 +269,8 @@ function parse_chunks( chunks.push({ type: NjcmChunkType.Bits, type_id, + src_alpha: (flags >>> 3) & 0b111, + dst_alpha: flags & 0b111, }); } else if (type_id === 4) { const cache_index = flags; @@ -265,9 +314,46 @@ function parse_chunks( }); } else if (17 <= type_id && type_id <= 31) { size = 2 + 2 * cursor.u16(); + + let diffuse: NjcmArgb | undefined; + let ambient: NjcmArgb | undefined; + let specular: NjcmErgb | undefined; + + if ((flags & 0b1) !== 0) { + diffuse = { + b: cursor.u8() / 255, + g: cursor.u8() / 255, + r: cursor.u8() / 255, + a: cursor.u8() / 255, + }; + } + + if ((flags & 0b10) !== 0) { + ambient = { + b: cursor.u8() / 255, + g: cursor.u8() / 255, + r: cursor.u8() / 255, + a: cursor.u8() / 255, + }; + } + + if ((flags & 0b100) !== 0) { + specular = { + b: cursor.u8(), + g: cursor.u8(), + r: cursor.u8(), + e: cursor.u8(), + }; + } + chunks.push({ type: NjcmChunkType.Material, type_id, + src_alpha: (flags >>> 3) & 0b111, + dst_alpha: flags & 0b111, + diffuse, + ambient, + specular, }); } else if (32 <= type_id && type_id <= 50) { size = 2 + 4 * cursor.u16(); diff --git a/src/core/rendering/conversion/GeometryBuilder.ts b/src/core/rendering/conversion/GeometryBuilder.ts index 3515cbf5..6306dad7 100644 --- a/src/core/rendering/conversion/GeometryBuilder.ts +++ b/src/core/rendering/conversion/GeometryBuilder.ts @@ -1,31 +1,67 @@ import { + Bone, BufferGeometry, Float32BufferAttribute, Uint16BufferAttribute, Vector3, - Bone, } from "three"; +import { map_get_or_put } from "../../util"; export type BuilderData = { - created_by_geometry_builder: boolean; - /** - * Maps material indices to normalized material indices. - */ - normalized_material_indices: Map; - bones: Bone[]; + readonly created_by_geometry_builder: boolean; + readonly materials: BuilderMaterial[]; + readonly bones: Bone[]; }; export type BuilderVec2 = { - x: number; - y: number; + readonly x: number; + readonly y: number; }; export type BuilderVec3 = { - x: number; - y: number; - z: number; + readonly x: number; + readonly y: number; + readonly z: number; }; +export type BuilderMaterial = { + readonly texture_id?: number; + readonly alpha: boolean; + readonly additive_blending: boolean; +}; + +/** + * Maps various material properties to material IDs. + */ +export class MaterialMap { + private readonly materials: BuilderMaterial[] = [{ alpha: false, additive_blending: false }]; + private readonly map = new Map(); + + /** + * Returns an index to an existing material if one exists for the given arguments. Otherwise + * adds a new material and returns its index. + */ + add_material( + texture_id?: number, + alpha: boolean = false, + additive_blending: boolean = false, + ): number { + if (texture_id == undefined) { + return 0; + } else { + const key = (texture_id << 2) | (alpha ? 0b10 : 0) | (additive_blending ? 1 : 0); + return map_get_or_put(this.map, key, () => { + this.materials.push({ texture_id, alpha, additive_blending }); + return this.materials.length - 1; + }); + } + } + + get_materials(): BuilderMaterial[] { + return this.materials; + } +} + type VertexGroup = { offset: number; size: number; @@ -33,18 +69,18 @@ type VertexGroup = { }; export class GeometryBuilder { - private positions: number[] = []; - private normals: number[] = []; - private uvs: number[] = []; - private indices: number[] = []; - private bones: Bone[] = []; - private bone_indices: number[] = []; - private bone_weights: number[] = []; - private groups: VertexGroup[] = []; + private readonly positions: number[] = []; + private readonly normals: number[] = []; + private readonly uvs: number[] = []; + private readonly indices: number[] = []; + private readonly bones: Bone[] = []; + private readonly bone_indices: number[] = []; + private readonly bone_weights: number[] = []; + private readonly groups: VertexGroup[] = []; /** * Will contain all material indices used in {@link this.groups} and -1 for the dummy material. */ - private material_indices = new Set([-1]); + private readonly material_map = new MaterialMap(); get vertex_count(): number { return this.positions.length / 3; @@ -89,26 +125,29 @@ export class GeometryBuilder { this.bone_weights.push(weight); } - add_group(offset: number, size: number, material_index?: number): void { + add_group( + offset: number, + size: number, + texture_id?: number, + alpha: boolean = false, + additive_blending: boolean = false, + ): void { const last_group = this.groups[this.groups.length - 1]; - const mat_idx = material_index == null ? -1 : material_index; + const material_index = this.material_map.add_material(texture_id, alpha, additive_blending); - if (last_group && last_group.material_index === mat_idx) { + if (last_group && last_group.material_index === material_index) { last_group.size += size; } else { this.groups.push({ offset, size, - material_index: mat_idx, + material_index, }); - this.material_indices.add(mat_idx); } } build(): BufferGeometry { const geom = new BufferGeometry(); - const data = geom.userData as BuilderData; - data.created_by_geometry_builder = true; geom.setAttribute("position", new Float32BufferAttribute(this.positions, 3)); geom.setAttribute("normal", new Float32BufferAttribute(this.normals, 3)); @@ -116,28 +155,28 @@ export class GeometryBuilder { geom.setIndex(new Uint16BufferAttribute(this.indices, 1)); + let bones: Bone[]; + if (this.bone_indices.length && this.bones.length) { geom.setAttribute("skinIndex", new Uint16BufferAttribute(this.bone_indices, 4)); geom.setAttribute("skinWeight", new Float32BufferAttribute(this.bone_weights, 4)); - data.bones = this.bones; + bones = this.bones; } else { - data.bones = []; + bones = []; } - // Normalize material indices. - const normalized_mat_idxs = new Map(); - let i = 0; - - for (const mat_idx of [...this.material_indices].sort((a, b) => a - b)) { - normalized_mat_idxs.set(mat_idx, i++); - } - - // Use normalized material indices in Three.js groups. for (const group of this.groups) { - geom.addGroup(group.offset, group.size, normalized_mat_idxs.get(group.material_index)); + geom.addGroup(group.offset, group.size, group.material_index); } - data.normalized_material_indices = normalized_mat_idxs; + // noinspection UnnecessaryLocalVariableJS + const data: BuilderData = { + created_by_geometry_builder: true, + materials: this.material_map.get_materials(), + bones, + }; + + geom.userData = data; geom.computeBoundingSphere(); geom.computeBoundingBox(); diff --git a/src/core/rendering/conversion/create_mesh.ts b/src/core/rendering/conversion/create_mesh.ts index 0f8f21b7..35dffb59 100644 --- a/src/core/rendering/conversion/create_mesh.ts +++ b/src/core/rendering/conversion/create_mesh.ts @@ -1,82 +1,78 @@ import { + AdditiveBlending, BufferGeometry, DoubleSide, Material, Mesh, + MeshBasicMaterial, MeshLambertMaterial, Skeleton, SkinnedMesh, + Texture, } from "three"; import { BuilderData } from "./GeometryBuilder"; +import { MeshBasicMaterialParameters } from "three/src/materials/MeshBasicMaterial"; const DUMMY_MATERIAL = new MeshLambertMaterial({ color: 0x00ff00, side: DoubleSide, }); -const DEFAULT_MATERIAL = new MeshLambertMaterial({ - color: 0xff00ff, - side: DoubleSide, -}); -const DEFAULT_SKINNED_MATERIAL = new MeshLambertMaterial({ - skinning: true, - color: 0xff00ff, - side: DoubleSide, -}); export function create_mesh( geometry: BufferGeometry, - material?: Material | Material[], - default_material: Material = DEFAULT_MATERIAL, -): Mesh { - return create(geometry, material, default_material, Mesh); -} - -export function create_skinned_mesh( - geometry: BufferGeometry, - material?: Material | Material[], - default_material: Material = DEFAULT_SKINNED_MATERIAL, -): SkinnedMesh { - return create(geometry, material, default_material, SkinnedMesh); -} - -function create( - geometry: BufferGeometry, - material: Material | Material[] | undefined, + textures: (Texture | undefined)[], default_material: Material, - mesh_constructor: new (geometry: BufferGeometry, material: Material | Material[]) => M, -): M { - const { - created_by_geometry_builder, - normalized_material_indices: mat_idxs, - bones, - } = geometry.userData as BuilderData; + skinning: boolean, +): Mesh { + const { created_by_geometry_builder, materials, bones } = geometry.userData as BuilderData; let mat: Material | Material[]; - if (Array.isArray(material)) { - if (created_by_geometry_builder) { - mat = [DUMMY_MATERIAL]; + if (textures.length && created_by_geometry_builder) { + mat = [DUMMY_MATERIAL]; - for (const [idx, normalized_idx] of mat_idxs.entries()) { - if (normalized_idx > 0) { - mat[normalized_idx] = material[idx] || default_material; + for (let i = 1; i < materials.length; i++) { + const { texture_id, alpha, additive_blending } = materials[i]; + const tex = texture_id == undefined ? undefined : textures[texture_id]; + + if (tex) { + const mat_params: MeshBasicMaterialParameters = { + skinning, + map: tex, + side: DoubleSide, + }; + + if (alpha) { + mat_params.transparent = true; + mat_params.alphaTest = 0.01; } + + if (additive_blending) { + mat_params.transparent = true; + mat_params.alphaTest = 0.01; + mat_params.blending = AdditiveBlending; + } + + mat.push(new MeshBasicMaterial(mat_params)); + } else { + mat.push( + new MeshLambertMaterial({ + skinning, + side: DoubleSide, + }), + ); } - } else { - mat = material; } - } else if (material) { - mat = material; } else { mat = default_material; } - const mesh = new mesh_constructor(geometry, mat); - - if (created_by_geometry_builder && bones.length && mesh instanceof SkinnedMesh) { + if (created_by_geometry_builder && bones.length && skinning) { + const mesh = new SkinnedMesh(geometry, mat); mesh.add(bones[0]); mesh.bind(new Skeleton(bones)); + return mesh; + } else { + return new Mesh(geometry, mat); } - - return mesh; } diff --git a/src/core/rendering/conversion/ninja_geometry.ts b/src/core/rendering/conversion/ninja_geometry.ts index 26c5e351..be7fcfdf 100644 --- a/src/core/rendering/conversion/ninja_geometry.ts +++ b/src/core/rendering/conversion/ninja_geometry.ts @@ -217,6 +217,8 @@ class GeometryCreator { start_index_count, this.builder.index_count - start_index_count, mesh.texture_id, + mesh.use_alpha, + mesh.src_alpha !== 4 || mesh.dst_alpha !== 5, ); } } diff --git a/src/quest_editor/rendering/conversion/entities.ts b/src/quest_editor/rendering/conversion/entities.ts index 67cd23a5..f334eef5 100644 --- a/src/quest_editor/rendering/conversion/entities.ts +++ b/src/quest_editor/rendering/conversion/entities.ts @@ -1,19 +1,11 @@ import { QuestEntityModel } from "../../model/QuestEntityModel"; -import { - BufferGeometry, - DoubleSide, - Mesh, - MeshBasicMaterial, - MeshLambertMaterial, - Texture, -} from "three"; +import { BufferGeometry, DoubleSide, Mesh, MeshLambertMaterial, Texture } from "three"; import { create_mesh } from "../../../core/rendering/conversion/create_mesh"; import { entity_type_to_string, EntityType, is_npc_type, } from "../../../core/data_formats/parsing/quest/entities"; -import { NpcType } from "../../../core/data_formats/parsing/quest/npc_types"; export enum ColorType { Normal, @@ -45,27 +37,8 @@ export function create_entity_type_mesh( side: DoubleSide, }); - const mesh = create_mesh( - geometry, - textures.length - ? textures.map( - tex => - new MeshBasicMaterial({ - map: tex, - side: DoubleSide, - // TODO: figure out why these NPC types don't render correctly when - // transparency is turned on. - transparent: - type !== NpcType.PofuillySlime && type !== NpcType.PouillySlime, - alphaTest: 0.01, - }), - ) - : default_material, - default_material, - ); - + const mesh = create_mesh(geometry, textures, default_material, false); mesh.name = entity_type_to_string(type); - return mesh; } diff --git a/src/viewer/loading/CharacterClassAssetLoader.ts b/src/viewer/loading/CharacterClassAssetLoader.ts index 8d885c5e..26bab762 100644 --- a/src/viewer/loading/CharacterClassAssetLoader.ts +++ b/src/viewer/loading/CharacterClassAssetLoader.ts @@ -263,8 +263,6 @@ function texture_ids( return { section_id: section_id + 275, body: [body_idx, body_idx + 1, body_idx + 2, body + 250], - // Eyes don't look correct because NJCM material chunks (which contain alpha blending - // details) aren't parsed yet. Material.blending should be AdditiveBlending. head: [body_idx + 3, body_idx + 4], hair: [], accessories: [], diff --git a/src/viewer/rendering/ModelRenderer.ts b/src/viewer/rendering/ModelRenderer.ts index e36a7d1e..932e4515 100644 --- a/src/viewer/rendering/ModelRenderer.ts +++ b/src/viewer/rendering/ModelRenderer.ts @@ -3,7 +3,6 @@ import { AnimationMixer, Clock, DoubleSide, - MeshBasicMaterial, MeshLambertMaterial, Object3D, PerspectiveCamera, @@ -14,7 +13,7 @@ import { import { Disposable } from "../../core/observable/Disposable"; import { NjMotion } from "../../core/data_formats/parsing/ninja/motion"; import { xvr_texture_to_texture } from "../../core/rendering/conversion/ninja_textures"; -import { create_mesh, create_skinned_mesh } from "../../core/rendering/conversion/create_mesh"; +import { create_mesh } from "../../core/rendering/conversion/create_mesh"; import { ninja_object_to_buffer_geometry } from "../../core/rendering/conversion/ninja_geometry"; import { create_animation_clip, @@ -150,25 +149,13 @@ export class ModelRenderer extends Renderer implements Disposable { const geometry = ninja_object_to_buffer_geometry(nj_object); const has_skeleton = geometry.getAttribute("skinIndex") != undefined; - const materials = textures.map(tex => - tex - ? new MeshBasicMaterial({ - skinning: has_skeleton, - map: tex, - side: DoubleSide, - alphaTest: 0.1, - transparent: true, - }) - : new MeshLambertMaterial({ - skinning: has_skeleton, - side: DoubleSide, - }), + this.mesh = create_mesh( + geometry, + textures, + has_skeleton ? DEFAULT_SKINNED_MATERIAL : DEFAULT_MATERIAL, + has_skeleton, ); - this.mesh = has_skeleton - ? create_skinned_mesh(geometry, materials, DEFAULT_SKINNED_MATERIAL) - : create_mesh(geometry, materials, DEFAULT_MATERIAL); - // Make sure we rotate around the center of the model instead of its origin. const bb = geometry.boundingBox; const height = bb.max.y - bb.min.y;