feat: add terrain-core, tma, and unitdat crates with parsing functionality

- Introduced `terrain-core` crate for loading and processing terrain mesh data.
- Added `tma` crate for parsing mission files, including footer and object records.
- Created `unitdat` crate for reading unit data files with validation of structure.
- Implemented error handling and tests for all new crates.
- Documented object registry format and rendering pipeline in specifications.
This commit is contained in:
2026-02-19 16:07:01 +04:00
parent 598137ed13
commit 4ef08d0bf6
12 changed files with 2973 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
[package]
name = "render-mission-demo"
version = "0.1.0"
edition = "2021"
[features]
default = []
demo = ["dep:sdl2", "dep:glow"]
[dependencies]
encoding_rs = "0.8"
glow = { version = "0.16", optional = true }
nres = { path = "../nres" }
render-core = { path = "../render-core" }
render-demo = { path = "../render-demo" }
tma = { path = "../tma" }
terrain-core = { path = "../terrain-core" }
texm = { path = "../texm" }
unitdat = { path = "../unitdat" }
[dev-dependencies]
common = { path = "../common" }
[target.'cfg(target_os = "macos")'.dependencies]
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["use-pkgconfig"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
[[bin]]
name = "parkan-render-mission-demo"
path = "src/main.rs"
required-features = ["demo"]

View File

@@ -0,0 +1,881 @@
use encoding_rs::WINDOWS_1251;
use nres::Archive;
use render_core::{build_render_mesh, RenderMesh};
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use terrain_core::TerrainMesh;
use tma::MissionFile;
const MAT0_KIND: u32 = 0x3054_414D;
const MESH_KIND: u32 = 0x4853_454D;
const OBJECT_REF_STRIDE: usize = 64;
const OBJECT_REF_ARCHIVE_BYTES: usize = 32;
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
Mission(tma::Error),
Terrain(terrain_core::Error),
UnitDat(unitdat::Error),
RenderDemo(render_demo::Error),
Nres(nres::error::Error),
Texm(texm::error::Error),
InvalidMapPath(String),
GameRootNotFound(PathBuf),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "{err}"),
Self::Mission(err) => write!(f, "{err}"),
Self::Terrain(err) => write!(f, "{err}"),
Self::UnitDat(err) => write!(f, "{err}"),
Self::RenderDemo(err) => write!(f, "{err}"),
Self::Nres(err) => write!(f, "{err}"),
Self::Texm(err) => write!(f, "{err}"),
Self::InvalidMapPath(path) => write!(f, "invalid mission map path: {path}"),
Self::GameRootNotFound(path) => {
write!(
f,
"failed to detect game root from mission path {}",
path.display()
)
}
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
Self::Mission(err) => Some(err),
Self::Terrain(err) => Some(err),
Self::UnitDat(err) => Some(err),
Self::RenderDemo(err) => Some(err),
Self::Nres(err) => Some(err),
Self::Texm(err) => Some(err),
Self::InvalidMapPath(_) | Self::GameRootNotFound(_) => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<tma::Error> for Error {
fn from(value: tma::Error) -> Self {
Self::Mission(value)
}
}
impl From<terrain_core::Error> for Error {
fn from(value: terrain_core::Error) -> Self {
Self::Terrain(value)
}
}
impl From<unitdat::Error> for Error {
fn from(value: unitdat::Error) -> Self {
Self::UnitDat(value)
}
}
impl From<render_demo::Error> for Error {
fn from(value: render_demo::Error) -> Self {
Self::RenderDemo(value)
}
}
impl From<nres::error::Error> for Error {
fn from(value: nres::error::Error) -> Self {
Self::Nres(value)
}
}
impl From<texm::error::Error> for Error {
fn from(value: texm::error::Error) -> Self {
Self::Texm(value)
}
}
#[derive(Copy, Clone, Debug)]
pub struct LoadOptions {
pub load_model_textures: bool,
pub load_terrain_texture: bool,
}
impl Default for LoadOptions {
fn default() -> Self {
Self {
load_model_textures: true,
load_terrain_texture: true,
}
}
}
#[derive(Clone, Debug)]
pub struct MissionScene {
pub game_root: PathBuf,
pub mission_path: PathBuf,
pub mission: MissionFile,
pub map_folder_rel: PathBuf,
pub land_msh_path: PathBuf,
pub terrain: TerrainMesh,
pub terrain_texture: Option<LoadedTexture>,
pub models: Vec<SceneModel>,
pub skipped_objects: usize,
}
#[derive(Clone, Debug)]
pub struct SceneModel {
pub archive_path: PathBuf,
pub model_name: String,
pub mesh: RenderMesh,
pub texture: Option<LoadedTexture>,
pub instances: Vec<ModelInstance>,
}
#[derive(Copy, Clone, Debug)]
pub struct ModelInstance {
pub position: [f32; 3],
pub yaw_rad: f32,
pub scale: [f32; 3],
}
#[derive(Clone, Debug)]
struct ObjectPrototype {
archive_path: PathBuf,
model_name: String,
}
#[derive(Clone, Debug)]
struct ObjectRef {
archive_name: String,
resource_name: String,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
struct ModelKey {
archive_path: PathBuf,
model_name: String,
}
pub fn detect_game_root_from_mission_path(mission_path: &Path) -> Option<PathBuf> {
let mut cursor = mission_path.parent();
while let Some(dir) = cursor {
if dir.join("DATA").is_dir() && dir.join("objects.rlb").is_file() {
return Some(dir.to_path_buf());
}
cursor = dir.parent();
}
None
}
pub fn load_scene(
game_root: impl AsRef<Path>,
mission_path: impl AsRef<Path>,
) -> Result<MissionScene> {
load_scene_with_options(game_root, mission_path, LoadOptions::default())
}
pub fn load_scene_with_options(
game_root: impl AsRef<Path>,
mission_path: impl AsRef<Path>,
options: LoadOptions,
) -> Result<MissionScene> {
let game_root = game_root.as_ref().to_path_buf();
let mission_path = mission_path.as_ref().to_path_buf();
let mission = tma::parse_path(&mission_path)?;
let map_folder_rel = map_folder_from_footer(&mission.footer.map_path)?;
let land_msh_path = game_root.join(&map_folder_rel).join("Land.msh");
let terrain = terrain_core::load_land_mesh(&land_msh_path)?;
let terrain_texture = if options.load_terrain_texture {
resolve_terrain_texture(&game_root, &map_folder_rel)?
} else {
None
};
let mut grouped_instances: HashMap<ModelKey, Vec<ModelInstance>> = HashMap::new();
let mut prototype_cache: HashMap<String, Option<ObjectPrototype>> = HashMap::new();
let mut skipped = 0usize;
for object in &mission.objects {
let cache_key = object.resource_name.to_ascii_lowercase();
let proto = if let Some(cached) = prototype_cache.get(&cache_key) {
cached.clone()
} else {
let resolved = resolve_object_prototype(&game_root, object)?;
prototype_cache.insert(cache_key, resolved.clone());
resolved
};
let Some(proto) = proto else {
skipped += 1;
continue;
};
let instance = ModelInstance {
position: object.position,
yaw_rad: object.orientation[2],
scale: normalize_scale(object.scale),
};
grouped_instances
.entry(ModelKey {
archive_path: proto.archive_path,
model_name: proto.model_name,
})
.or_default()
.push(instance);
}
let mut models = Vec::new();
for (key, instances) in grouped_instances {
let loaded =
match load_model_with_name_from_archive(&key.archive_path, Some(&key.model_name)) {
Ok(v) => v,
Err(_) => {
skipped += instances.len();
continue;
}
};
let mesh = build_render_mesh(&loaded.model, 0, 0);
if mesh.indices.is_empty() {
skipped += instances.len();
continue;
}
let texture = if options.load_model_textures {
resolve_texture_for_model(&key.archive_path, &loaded.name, None, None, None, None)
.ok()
.flatten()
} else {
None
};
models.push(SceneModel {
archive_path: key.archive_path,
model_name: loaded.name,
mesh,
texture,
instances,
});
}
models.sort_by(|a, b| a.model_name.cmp(&b.model_name));
Ok(MissionScene {
game_root,
mission_path,
mission,
map_folder_rel,
land_msh_path,
terrain,
terrain_texture,
models,
skipped_objects: skipped,
})
}
pub fn compute_scene_bounds(scene: &MissionScene) -> Option<([f32; 3], [f32; 3])> {
let mut min_v = [f32::INFINITY; 3];
let mut max_v = [f32::NEG_INFINITY; 3];
let mut any = false;
for pos in &scene.terrain.positions {
merge_bounds(&mut min_v, &mut max_v, *pos);
any = true;
}
for model in &scene.models {
for instance in &model.instances {
merge_bounds(&mut min_v, &mut max_v, instance.position);
any = true;
}
}
any.then_some((min_v, max_v))
}
fn merge_bounds(min_v: &mut [f32; 3], max_v: &mut [f32; 3], p: [f32; 3]) {
for i in 0..3 {
if p[i] < min_v[i] {
min_v[i] = p[i];
}
if p[i] > max_v[i] {
max_v[i] = p[i];
}
}
}
fn normalize_scale(scale: [f32; 3]) -> [f32; 3] {
let mut out = scale;
for item in &mut out {
if !item.is_finite() || item.abs() < 0.000_1 {
*item = 1.0;
}
}
out
}
fn map_folder_from_footer(map_path: &str) -> Result<PathBuf> {
let mut parts = split_relative_path(map_path);
if parts.len() < 2 {
return Err(Error::InvalidMapPath(map_path.to_string()));
}
parts.pop(); // remove 'land'
let mut out = PathBuf::new();
for part in parts {
out.push(part);
}
Ok(out)
}
fn resolve_object_prototype(
game_root: &Path,
object: &tma::MissionObject,
) -> Result<Option<ObjectPrototype>> {
if object.resource_name.to_ascii_lowercase().ends_with(".dat") {
let dat_path = game_root.join(pathbuf_from_rel(&object.resource_name));
if !dat_path.is_file() {
return Ok(None);
}
let parsed = unitdat::parse_path(&dat_path)?;
let archive_path = game_root.join(pathbuf_from_rel(&parsed.archive_name));
if !archive_path.is_file() {
return Ok(None);
}
return resolve_archive_model(game_root, &archive_path, &parsed.model_key);
}
let archive_path = game_root.join("objects.rlb");
if !archive_path.is_file() {
return Ok(None);
}
resolve_archive_model(game_root, &archive_path, &object.resource_name)
}
fn resolve_archive_model(
game_root: &Path,
archive_path: &Path,
model_key: &str,
) -> Result<Option<ObjectPrototype>> {
if !archive_path.is_file() {
return Ok(None);
}
if is_objects_registry_archive(archive_path) {
if let Some(proto) = resolve_objects_registry_model(game_root, archive_path, model_key)? {
return Ok(Some(proto));
}
}
let model_name = ensure_msh_suffix(model_key);
if !archive_has_mesh_entry(archive_path, &model_name)? {
return Ok(None);
}
Ok(Some(ObjectPrototype {
archive_path: archive_path.to_path_buf(),
model_name: model_name.to_ascii_lowercase(),
}))
}
fn is_objects_registry_archive(archive_path: &Path) -> bool {
archive_path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("objects.rlb"))
}
fn resolve_objects_registry_model(
game_root: &Path,
registry_archive_path: &Path,
object_key: &str,
) -> Result<Option<ObjectPrototype>> {
let archive = Archive::open_path(registry_archive_path)?;
let Some(entry_id) = find_registry_entry_id(&archive, object_key) else {
return Ok(None);
};
let payload = archive.read(entry_id)?.into_owned();
let refs = parse_object_refs(&payload);
if refs.is_empty() {
return Ok(None);
}
for item in refs
.iter()
.filter(|item| has_extension(&item.resource_name, "msh"))
{
if let Some(proto) = resolve_object_ref_model(game_root, item, &item.resource_name)? {
return Ok(Some(proto));
}
}
for item in refs
.iter()
.filter(|item| has_extension(&item.resource_name, "bas"))
{
let Some(stem) = Path::new(&item.resource_name)
.file_stem()
.and_then(|stem| stem.to_str())
else {
continue;
};
if stem.is_empty() {
continue;
}
let candidate = format!("{stem}.msh");
if let Some(proto) = resolve_object_ref_model(game_root, item, &candidate)? {
return Ok(Some(proto));
}
}
Ok(None)
}
fn find_registry_entry_id(archive: &Archive, object_key: &str) -> Option<nres::EntryId> {
mesh_name_candidates(object_key)
.into_iter()
.find_map(|candidate| archive.find(&candidate))
}
fn resolve_object_ref_model(
game_root: &Path,
item: &ObjectRef,
model_name: &str,
) -> Result<Option<ObjectPrototype>> {
let archive_path = game_root.join(pathbuf_from_rel(&item.archive_name));
if !archive_path.is_file() {
return Ok(None);
}
if !archive_has_mesh_entry(&archive_path, model_name)? {
return Ok(None);
}
Ok(Some(ObjectPrototype {
archive_path,
model_name: model_name.to_ascii_lowercase(),
}))
}
fn parse_object_refs(payload: &[u8]) -> Vec<ObjectRef> {
if !payload.len().is_multiple_of(OBJECT_REF_STRIDE) {
return Vec::new();
}
let mut refs = Vec::with_capacity(payload.len() / OBJECT_REF_STRIDE);
for chunk in payload.chunks_exact(OBJECT_REF_STRIDE) {
let archive_name = decode_cp1251_cstr(&chunk[..OBJECT_REF_ARCHIVE_BYTES]);
let resource_name = decode_cp1251_cstr(&chunk[OBJECT_REF_ARCHIVE_BYTES..]);
if archive_name.is_empty() || resource_name.is_empty() {
continue;
}
refs.push(ObjectRef {
archive_name,
resource_name,
});
}
refs
}
fn archive_has_mesh_entry(archive_path: &Path, requested_name: &str) -> Result<bool> {
let archive = Archive::open_path(archive_path)?;
Ok(find_mesh_entry_id(&archive, requested_name).is_some())
}
fn find_mesh_entry_id(archive: &Archive, requested_name: &str) -> Option<nres::EntryId> {
for candidate in mesh_name_candidates(requested_name) {
let Some(id) = archive.find(&candidate) else {
continue;
};
let Some(entry) = archive.get(id) else {
continue;
};
if entry.meta.kind == MESH_KIND || has_extension(&entry.meta.name, "msh") {
return Some(id);
}
}
None
}
fn mesh_name_candidates(name: &str) -> Vec<String> {
let mut out = Vec::new();
let trimmed = name.trim();
if trimmed.is_empty() {
return out;
}
push_unique_string(&mut out, trimmed.to_string());
if let Some(stem) = trimmed
.strip_suffix(".msh")
.or_else(|| trimmed.strip_suffix(".MSH"))
{
if !stem.is_empty() {
push_unique_string(&mut out, stem.to_string());
}
} else {
push_unique_string(&mut out, format!("{trimmed}.msh"));
}
out
}
fn push_unique_string(items: &mut Vec<String>, value: String) {
if !items.iter().any(|item| item.eq_ignore_ascii_case(&value)) {
items.push(value);
}
}
fn ensure_msh_suffix(name: &str) -> String {
let trimmed = name.trim();
if trimmed.to_ascii_lowercase().ends_with(".msh") {
trimmed.to_string()
} else {
format!("{trimmed}.msh")
}
}
fn has_extension(name: &str, ext: &str) -> bool {
Path::new(name)
.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case(ext))
}
fn resolve_terrain_texture(
game_root: &Path,
map_folder_rel: &Path,
) -> Result<Option<LoadedTexture>> {
let material_archive_path = game_root.join("material.lib");
let texture_archive_path = game_root.join("textures.lib");
if !material_archive_path.is_file() || !texture_archive_path.is_file() {
return Ok(None);
}
for wear_name in ["Land1.wea", "Land2.wea"] {
let wear_path = game_root.join(map_folder_rel).join(wear_name);
if !wear_path.is_file() {
continue;
}
let wear_payload = fs::read(&wear_path)?;
let Some(material_name) = parse_primary_material_from_wear(&wear_payload) else {
continue;
};
let Some(texture_name) =
resolve_texture_name_from_material_archive(&material_archive_path, &material_name)?
else {
continue;
};
if let Some(texture) = load_texm_by_name(&texture_archive_path, &texture_name)? {
return Ok(Some(texture));
}
}
Ok(None)
}
fn parse_primary_material_from_wear(bytes: &[u8]) -> Option<String> {
let text = decode_cp1251(bytes).replace('\r', "");
let mut lines = text.lines();
let count = lines.next()?.trim().parse::<usize>().ok()?;
if count == 0 {
return None;
}
for line in lines.take(count) {
let mut parts = line.split_whitespace();
let _legacy = parts.next()?;
let name = parts.next()?;
if !name.is_empty() {
return Some(name.to_string());
}
}
None
}
fn resolve_texture_name_from_material_archive(
archive_path: &Path,
material_name: &str,
) -> Result<Option<String>> {
let archive = Archive::open_path(archive_path)?;
let entry = if let Some(id) = archive.find(material_name) {
archive
.get(id)
.filter(|entry| entry.meta.kind == MAT0_KIND)
.or_else(|| {
archive
.find("DEFAULT")
.and_then(|id| archive.get(id))
.filter(|entry| entry.meta.kind == MAT0_KIND)
})
} else {
archive
.find("DEFAULT")
.and_then(|id| archive.get(id))
.filter(|entry| entry.meta.kind == MAT0_KIND)
}
.or_else(|| archive.entries().find(|entry| entry.meta.kind == MAT0_KIND));
let Some(entry) = entry else {
return Ok(None);
};
let payload = archive.read(entry.id)?.into_owned();
parse_primary_texture_name_from_mat0(&payload, entry.meta.attr2)
}
fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
if payload.len() < 4 {
return Ok(None);
}
let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
if phase_count == 0 {
return Ok(None);
}
let mut offset = 4usize;
if attr2 >= 2 {
offset = offset.saturating_add(2);
}
if attr2 >= 3 {
offset = offset.saturating_add(4);
}
if attr2 >= 4 {
offset = offset.saturating_add(4);
}
for phase in 0..phase_count {
let phase_off = offset.saturating_add(phase.saturating_mul(34));
let Some(rec) = payload.get(phase_off..phase_off + 34) else {
break;
};
let name_raw = &rec[18..34];
let end = name_raw
.iter()
.position(|&b| b == 0)
.unwrap_or(name_raw.len());
let name = decode_cp1251(&name_raw[..end]).trim().to_string();
if !name.is_empty() {
return Ok(Some(name));
}
}
Ok(None)
}
fn load_texm_by_name(archive_path: &Path, texture_name: &str) -> Result<Option<LoadedTexture>> {
let archive = Archive::open_path(archive_path)?;
let Some(id) = archive.find(texture_name) else {
return Ok(None);
};
let Some(entry) = archive.get(id) else {
return Ok(None);
};
if entry.meta.kind != texm::TEXM_MAGIC {
return Ok(None);
}
let payload = archive.read(id)?.into_owned();
let parsed = texm::parse_texm(&payload)?;
let decoded = texm::decode_mip_rgba8(&parsed, &payload, 0)?;
Ok(Some(LoadedTexture {
name: entry.meta.name.clone(),
width: decoded.width,
height: decoded.height,
rgba8: decoded.rgba8,
}))
}
fn split_relative_path(path: &str) -> Vec<&str> {
path.split(['\\', '/'])
.filter(|part| !part.is_empty())
.collect()
}
fn pathbuf_from_rel(path: &str) -> PathBuf {
let mut out = PathBuf::new();
for part in split_relative_path(path) {
out.push(part);
}
out
}
fn decode_cp1251_cstr(bytes: &[u8]) -> String {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..end]);
decoded.trim().to_string()
}
fn decode_cp1251(bytes: &[u8]) -> String {
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
decoded.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn game_root() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("Parkan - Iron Strategy");
root.is_dir().then_some(root)
}
#[test]
fn detects_game_root_from_mission_path() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let mission = root
.join("MISSIONS")
.join("CAMPAIGN")
.join("CAMPAIGN.00")
.join("Mission.01")
.join("data.tma");
if !mission.is_file() {
eprintln!("skipping missing mission sample");
return;
}
let detected = detect_game_root_from_mission_path(&mission)
.expect("failed to detect game root from mission path");
assert_eq!(detected, root);
}
#[test]
fn loads_scene_cpu_without_textures() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let mission = root
.join("MISSIONS")
.join("CAMPAIGN")
.join("CAMPAIGN.00")
.join("Mission.01")
.join("data.tma");
if !mission.is_file() {
eprintln!("skipping missing mission sample");
return;
}
let scene = load_scene_with_options(
&root,
&mission,
LoadOptions {
load_model_textures: false,
load_terrain_texture: false,
},
)
.unwrap_or_else(|err| panic!("failed to load scene {}: {err}", mission.display()));
assert!(!scene.terrain.positions.is_empty());
assert!(!scene.terrain.faces.is_empty());
assert!(!scene.models.is_empty());
let instance_count = scene
.models
.iter()
.map(|model| model.instances.len())
.sum::<usize>();
assert!(instance_count >= 10);
let bounds = compute_scene_bounds(&scene).expect("scene bounds should exist");
assert!(bounds.0[0] <= bounds.1[0]);
assert!(bounds.0[1] <= bounds.1[1]);
assert!(bounds.0[2] <= bounds.1[2]);
}
#[test]
fn loads_scene_with_textures() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let mission = root
.join("MISSIONS")
.join("CAMPAIGN")
.join("CAMPAIGN.00")
.join("Mission.01")
.join("data.tma");
if !mission.is_file() {
eprintln!("skipping missing mission sample");
return;
}
let scene = load_scene_with_options(&root, &mission, LoadOptions::default())
.unwrap_or_else(|err| panic!("failed to load textured scene {}: {err}", mission.display()));
assert!(!scene.models.is_empty());
let textured_models = scene.models.iter().filter(|model| model.texture.is_some()).count();
assert!(textured_models > 0, "no model textures resolved");
assert!(scene.terrain_texture.is_some(), "terrain texture was not resolved");
}
#[test]
fn resolves_objects_registry_models() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let registry = root.join("objects.rlb");
if !registry.is_file() {
eprintln!("skipping missing objects.rlb");
return;
}
let cases = [
("r_h_01", "bases.rlb", "r_h_01.msh"),
("s_tree_04", "static.rlb", "s_tree_0_04.msh"),
("fr_m_brige", "fortif.rlb", "fr_m_brige.msh"),
];
for (key, archive_name, model_name) in cases {
let proto = resolve_objects_registry_model(&root, &registry, key)
.unwrap_or_else(|err| panic!("failed to resolve '{key}' from objects.rlb: {err}"))
.unwrap_or_else(|| panic!("missing model resolution for '{key}'"));
let got_archive = proto
.archive_path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_ascii_lowercase())
.unwrap_or_default();
assert_eq!(got_archive, archive_name.to_ascii_lowercase());
assert!(
proto.model_name.eq_ignore_ascii_case(model_name),
"unexpected model for key '{key}': got '{}', expected '{}'",
proto.model_name,
model_name
);
}
}
}

View File

@@ -0,0 +1,924 @@
use glow::HasContext as _;
use render_mission_demo::{
compute_scene_bounds, detect_game_root_from_mission_path, load_scene_with_options, LoadOptions,
MissionScene, ModelInstance,
};
use std::io::Write as _;
use std::path::PathBuf;
use std::time::{Duration, Instant};
struct Args {
mission: PathBuf,
game_root: Option<PathBuf>,
width: u32,
height: u32,
fov_deg: f32,
no_model_texture: bool,
no_terrain_texture: bool,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum GlBackend {
Gles2,
Core33,
}
struct GpuTexture {
handle: glow::NativeTexture,
}
struct GpuRenderable {
vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
index_count: usize,
texture: Option<GpuTexture>,
}
struct ModelRenderable {
gpu: GpuRenderable,
instances: Vec<ModelInstance>,
}
#[derive(Copy, Clone, Debug)]
struct Camera {
position: [f32; 3],
yaw: f32,
pitch: f32,
move_speed: f32,
mouse_sensitivity: f32,
}
fn parse_args() -> Result<Args, String> {
let mut mission = None;
let mut game_root = None;
let mut width = 1600u32;
let mut height = 900u32;
let mut fov_deg = 60.0f32;
let mut no_model_texture = false;
let mut no_terrain_texture = false;
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--mission" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --mission"))?;
mission = Some(PathBuf::from(value));
}
"--game-root" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --game-root"))?;
game_root = Some(PathBuf::from(value));
}
"--width" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --width"))?;
width = value
.parse::<u32>()
.map_err(|_| String::from("invalid --width value"))?;
if width == 0 {
return Err(String::from("--width must be > 0"));
}
}
"--height" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --height"))?;
height = value
.parse::<u32>()
.map_err(|_| String::from("invalid --height value"))?;
if height == 0 {
return Err(String::from("--height must be > 0"));
}
}
"--fov" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --fov"))?;
fov_deg = value
.parse::<f32>()
.map_err(|_| String::from("invalid --fov value"))?;
if !(1.0..=179.0).contains(&fov_deg) {
return Err(String::from("--fov must be in range [1, 179]"));
}
}
"--no-model-texture" => {
no_model_texture = true;
}
"--no-terrain-texture" => {
no_terrain_texture = true;
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => {
return Err(format!("unknown argument: {other}"));
}
}
}
let mission = mission.ok_or_else(|| String::from("missing required --mission"))?;
Ok(Args {
mission,
game_root,
width,
height,
fov_deg,
no_model_texture,
no_terrain_texture,
})
}
fn print_help() {
eprintln!("parkan-render-mission-demo --mission <path/to/data.tma> [--game-root <path>] [--width W] [--height H] [--fov DEG]");
eprintln!(" [--no-model-texture] [--no-terrain-texture]");
eprintln!("controls: arrows/WASD move, PageUp/PageDown vertical move, Right Mouse drag look, Shift speed-up, Esc exit");
}
fn main() {
let args = match parse_args() {
Ok(v) => v,
Err(err) => {
eprintln!("{err}");
print_help();
std::process::exit(2);
}
};
if let Err(err) = run(args) {
eprintln!("{err}");
std::process::exit(1);
}
}
fn run(args: Args) -> Result<(), String> {
let game_root = if let Some(path) = args.game_root.clone() {
path
} else {
detect_game_root_from_mission_path(&args.mission).ok_or_else(|| {
format!(
"failed to detect game root from mission path {} (use --game-root)",
args.mission.display()
)
})?
};
let scene = load_scene_with_options(
&game_root,
&args.mission,
LoadOptions {
load_model_textures: !args.no_model_texture,
load_terrain_texture: !args.no_terrain_texture,
},
)
.map_err(|err| format!("failed to load mission scene: {err}"))?;
let terrain_mesh = terrain_core::build_render_mesh(&scene.terrain)
.map_err(|err| format!("failed to build terrain render mesh: {err}"))?;
let instance_count = scene
.models
.iter()
.map(|model| model.instances.len())
.sum::<usize>();
println!(
"mission loaded: map='{}', terrain_vertices={}, terrain_faces={}, models={}, instances={}, skipped={}",
scene.mission.footer.map_path,
scene.terrain.positions.len(),
scene.terrain.faces.len(),
scene.models.len(),
instance_count,
scene.skipped_objects
);
let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
let video = sdl
.video()
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
let (mut window, _gl_ctx, gl_backend) =
create_window_and_context(&video, args.width, args.height)?;
let _ = video.gl_set_swap_interval(1);
let gl = unsafe {
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
};
let program = unsafe { create_program(&gl, gl_backend)? };
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
.ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") }
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
let terrain_gpu =
unsafe { upload_terrain_renderable(&gl, &terrain_mesh, scene.terrain_texture.as_ref())? };
let mut model_gpus = Vec::new();
for model in &scene.models {
let renderable = unsafe { upload_model_renderable(&gl, model)? };
model_gpus.push(renderable);
}
let (scene_center, scene_radius) = initial_scene_sphere(&scene);
let mut camera = Camera {
position: [
scene_center[0],
scene_center[1] + scene_radius * 0.6,
scene_center[2] + scene_radius * 1.4,
],
yaw: std::f32::consts::PI,
pitch: -0.28,
move_speed: (scene_radius * 0.55).max(60.0),
mouse_sensitivity: 0.005,
};
let mut events = sdl
.event_pump()
.map_err(|err| format!("failed to get SDL event pump: {err}"))?;
let mut last = Instant::now();
let mut fps_window_start = Instant::now();
let mut fps_frames = 0u32;
let mut fps_printed = false;
let mut mouse_look = false;
'main_loop: loop {
for event in events.poll_iter() {
match event {
sdl2::event::Event::Quit { .. } => break 'main_loop,
sdl2::event::Event::KeyDown {
keycode: Some(sdl2::keyboard::Keycode::Escape),
..
} => break 'main_loop,
sdl2::event::Event::MouseButtonDown {
mouse_btn: sdl2::mouse::MouseButton::Right,
..
} => {
mouse_look = true;
sdl.mouse().set_relative_mouse_mode(true);
}
sdl2::event::Event::MouseButtonUp {
mouse_btn: sdl2::mouse::MouseButton::Right,
..
} => {
mouse_look = false;
sdl.mouse().set_relative_mouse_mode(false);
}
sdl2::event::Event::MouseMotion { xrel, yrel, .. } if mouse_look => {
camera.yaw += xrel as f32 * camera.mouse_sensitivity;
camera.pitch -= yrel as f32 * camera.mouse_sensitivity;
camera.pitch = camera.pitch.clamp(-1.54, 1.54);
}
_ => {}
}
}
let now = Instant::now();
let dt = (now - last).as_secs_f32().clamp(0.0, 0.05);
last = now;
update_camera(&events, &mut camera, dt);
let (w, h) = window.size();
let proj = mat4_perspective(
args.fov_deg.to_radians(),
(w as f32 / h.max(1) as f32).max(0.01),
0.1,
(scene_radius * 25.0).max(5000.0),
);
let forward = camera_forward(camera.yaw, camera.pitch);
let view = mat4_look_at(
camera.position,
[
camera.position[0] + forward[0],
camera.position[1] + forward[1],
camera.position[2] + forward[2],
],
[0.0, 1.0, 0.0],
);
unsafe {
draw_frame_begin(&gl, w, h);
let terrain_mvp = mat4_mul(&proj, &view);
draw_gpu_renderable(
&gl,
program,
u_mvp.as_ref(),
u_use_tex.as_ref(),
u_tex.as_ref(),
a_pos,
a_uv,
&terrain_gpu,
&terrain_mvp,
);
for model in &model_gpus {
for instance in &model.instances {
let model_m = model_matrix(instance.position, instance.yaw_rad, instance.scale);
let view_model = mat4_mul(&view, &model_m);
let mvp = mat4_mul(&proj, &view_model);
draw_gpu_renderable(
&gl,
program,
u_mvp.as_ref(),
u_use_tex.as_ref(),
u_tex.as_ref(),
a_pos,
a_uv,
&model.gpu,
&mvp,
);
}
}
}
window.gl_swap_window();
fps_frames = fps_frames.saturating_add(1);
let elapsed = fps_window_start.elapsed();
if elapsed >= Duration::from_millis(500) {
let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
let frame_time_ms = 1000.0 / fps.max(0.000_1);
let _ = window.set_title(&format!(
"Parkan Mission Demo | FPS: {fps:.1} ({frame_time_ms:.2} ms) | objects: {instance_count}"
));
print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
let _ = std::io::stdout().flush();
fps_printed = true;
fps_frames = 0;
fps_window_start = Instant::now();
}
}
if fps_printed {
println!();
}
unsafe {
cleanup_renderable(&gl, terrain_gpu);
for model in model_gpus {
cleanup_renderable(&gl, model.gpu);
}
gl.delete_program(program);
}
Ok(())
}
fn initial_scene_sphere(scene: &MissionScene) -> ([f32; 3], f32) {
if let Some((min_v, max_v)) = compute_scene_bounds(scene) {
let center = [
0.5 * (min_v[0] + max_v[0]),
0.5 * (min_v[1] + max_v[1]),
0.5 * (min_v[2] + max_v[2]),
];
let extent = [
max_v[0] - min_v[0],
max_v[1] - min_v[1],
max_v[2] - min_v[2],
];
let radius = ((extent[0] * extent[0]) + (extent[1] * extent[1]) + (extent[2] * extent[2]))
.sqrt()
.max(10.0)
* 0.5;
return (center, radius);
}
([0.0, 0.0, 0.0], 100.0)
}
fn update_camera(events: &sdl2::EventPump, camera: &mut Camera, dt: f32) {
use sdl2::keyboard::Scancode;
let keys = events.keyboard_state();
let mut move_dir = [0.0f32, 0.0f32, 0.0f32];
let forward = camera_forward(camera.yaw, camera.pitch);
let right = normalize3(cross3(forward, [0.0, 1.0, 0.0]));
if keys.is_scancode_pressed(Scancode::Up) || keys.is_scancode_pressed(Scancode::W) {
move_dir[0] += forward[0];
move_dir[1] += forward[1];
move_dir[2] += forward[2];
}
if keys.is_scancode_pressed(Scancode::Down) || keys.is_scancode_pressed(Scancode::S) {
move_dir[0] -= forward[0];
move_dir[1] -= forward[1];
move_dir[2] -= forward[2];
}
if keys.is_scancode_pressed(Scancode::Left) || keys.is_scancode_pressed(Scancode::A) {
move_dir[0] -= right[0];
move_dir[1] -= right[1];
move_dir[2] -= right[2];
}
if keys.is_scancode_pressed(Scancode::Right) || keys.is_scancode_pressed(Scancode::D) {
move_dir[0] += right[0];
move_dir[1] += right[1];
move_dir[2] += right[2];
}
if keys.is_scancode_pressed(Scancode::PageUp) || keys.is_scancode_pressed(Scancode::E) {
move_dir[1] += 1.0;
}
if keys.is_scancode_pressed(Scancode::PageDown) || keys.is_scancode_pressed(Scancode::Q) {
move_dir[1] -= 1.0;
}
let shift =
keys.is_scancode_pressed(Scancode::LShift) || keys.is_scancode_pressed(Scancode::RShift);
let speed_mul = if shift { 3.0 } else { 1.0 };
let norm = normalize3(move_dir);
camera.position[0] += norm[0] * camera.move_speed * speed_mul * dt;
camera.position[1] += norm[1] * camera.move_speed * speed_mul * dt;
camera.position[2] += norm[2] * camera.move_speed * speed_mul * dt;
}
unsafe fn upload_model_renderable(
gl: &glow::Context,
model: &render_mission_demo::SceneModel,
) -> Result<ModelRenderable, String> {
let mut vertex_data = Vec::with_capacity(model.mesh.vertices.len() * 5);
for vertex in &model.mesh.vertices {
vertex_data.push(vertex.position[0]);
vertex_data.push(vertex.position[1]);
vertex_data.push(vertex.position[2]);
vertex_data.push(vertex.uv0[0]);
vertex_data.push(vertex.uv0[1]);
}
let gpu = upload_gpu_renderable(
gl,
&vertex_data,
&model.mesh.indices,
model.texture.as_ref(),
)?;
Ok(ModelRenderable {
gpu,
instances: model.instances.clone(),
})
}
unsafe fn upload_terrain_renderable(
gl: &glow::Context,
mesh: &terrain_core::TerrainRenderMesh,
texture: Option<&render_demo::LoadedTexture>,
) -> Result<GpuRenderable, String> {
let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
for vertex in &mesh.vertices {
vertex_data.push(vertex.position[0]);
vertex_data.push(vertex.position[1]);
vertex_data.push(vertex.position[2]);
vertex_data.push(vertex.uv0[0]);
vertex_data.push(vertex.uv0[1]);
}
upload_gpu_renderable(gl, &vertex_data, &mesh.indices, texture)
}
unsafe fn upload_gpu_renderable(
gl: &glow::Context,
vertices: &[f32],
indices: &[u16],
texture: Option<&render_demo::LoadedTexture>,
) -> Result<GpuRenderable, String> {
let vbo = gl.create_buffer().map_err(|e| e.to_string())?;
let ebo = gl.create_buffer().map_err(|e| e.to_string())?;
let vertex_bytes = f32_slice_to_ne_bytes(vertices);
let index_bytes = u16_slice_to_ne_bytes(indices);
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
gl.bind_buffer(glow::ARRAY_BUFFER, None);
let gpu_texture = if let Some(texture) = texture {
Some(create_texture(gl, texture)?)
} else {
None
};
Ok(GpuRenderable {
vbo,
ebo,
index_count: indices.len(),
texture: gpu_texture,
})
}
unsafe fn cleanup_renderable(gl: &glow::Context, renderable: GpuRenderable) {
if let Some(tex) = renderable.texture {
gl.delete_texture(tex.handle);
}
gl.delete_buffer(renderable.ebo);
gl.delete_buffer(renderable.vbo);
}
unsafe fn draw_frame_begin(gl: &glow::Context, width: u32, height: u32) {
gl.viewport(
0,
0,
width.min(i32::MAX as u32) as i32,
height.min(i32::MAX as u32) as i32,
);
gl.enable(glow::DEPTH_TEST);
gl.clear_color(0.06, 0.08, 0.12, 1.0);
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
}
unsafe fn draw_gpu_renderable(
gl: &glow::Context,
program: glow::NativeProgram,
u_mvp: Option<&glow::NativeUniformLocation>,
u_use_tex: Option<&glow::NativeUniformLocation>,
u_tex: Option<&glow::NativeUniformLocation>,
a_pos: u32,
a_uv: u32,
renderable: &GpuRenderable,
mvp: &[f32; 16],
) {
gl.use_program(Some(program));
gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
let texture_enabled = renderable.texture.is_some();
gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
if let Some(tex) = &renderable.texture {
gl.active_texture(glow::TEXTURE0);
gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle));
gl.uniform_1_i32(u_tex, 0);
} else {
gl.bind_texture(glow::TEXTURE_2D, None);
}
gl.bind_buffer(glow::ARRAY_BUFFER, Some(renderable.vbo));
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(renderable.ebo));
gl.enable_vertex_attrib_array(a_pos);
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
gl.enable_vertex_attrib_array(a_uv);
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
gl.draw_elements(
glow::TRIANGLES,
renderable.index_count.min(i32::MAX as usize) as i32,
glow::UNSIGNED_SHORT,
0,
);
gl.disable_vertex_attrib_array(a_uv);
gl.disable_vertex_attrib_array(a_pos);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
gl.bind_buffer(glow::ARRAY_BUFFER, None);
gl.bind_texture(glow::TEXTURE_2D, None);
gl.use_program(None);
}
fn create_window_and_context(
video: &sdl2::VideoSubsystem,
width: u32,
height: u32,
) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
let candidates = [
(GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
(GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
];
let mut errors = Vec::new();
for (backend, profile, major, minor) in candidates {
{
let gl_attr = video.gl_attr();
gl_attr.set_context_profile(profile);
gl_attr.set_context_version(major, minor);
gl_attr.set_depth_size(24);
gl_attr.set_double_buffer(true);
}
let mut window_builder = video.window("Parkan Mission Demo", width, height);
window_builder.opengl().resizable();
let window = match window_builder.build() {
Ok(window) => window,
Err(err) => {
errors.push(format!(
"{profile:?} {major}.{minor}: window build failed ({err})"
));
continue;
}
};
let gl_ctx = match window.gl_create_context() {
Ok(ctx) => ctx,
Err(err) => {
errors.push(format!(
"{profile:?} {major}.{minor}: context create failed ({err})"
));
continue;
}
};
if let Err(err) = window.gl_make_current(&gl_ctx) {
errors.push(format!(
"{profile:?} {major}.{minor}: make current failed ({err})"
));
continue;
}
return Ok((window, gl_ctx, backend));
}
Err(format!(
"failed to create OpenGL context. Attempts: {}",
errors.join(" | ")
))
}
unsafe fn create_texture(
gl: &glow::Context,
texture: &render_demo::LoadedTexture,
) -> Result<GpuTexture, String> {
let handle = gl.create_texture().map_err(|e| e.to_string())?;
gl.bind_texture(glow::TEXTURE_2D, Some(handle));
gl.tex_parameter_i32(
glow::TEXTURE_2D,
glow::TEXTURE_MIN_FILTER,
glow::LINEAR as i32,
);
gl.tex_parameter_i32(
glow::TEXTURE_2D,
glow::TEXTURE_MAG_FILTER,
glow::LINEAR as i32,
);
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
gl.tex_image_2d(
glow::TEXTURE_2D,
0,
glow::RGBA as i32,
texture.width.min(i32::MAX as u32) as i32,
texture.height.min(i32::MAX as u32) as i32,
0,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())),
);
gl.bind_texture(glow::TEXTURE_2D, None);
Ok(GpuTexture { handle })
}
unsafe fn create_program(
gl: &glow::Context,
backend: GlBackend,
) -> Result<glow::NativeProgram, String> {
let (vs_src, fs_src) = match backend {
GlBackend::Gles2 => (
r#"
attribute vec3 a_pos;
attribute vec2 a_uv;
uniform mat4 u_mvp;
varying vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = u_mvp * vec4(a_pos, 1.0);
}
"#,
r#"
precision mediump float;
uniform sampler2D u_tex;
uniform float u_use_tex;
varying vec2 v_uv;
void main() {
vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
vec4 texColor = texture2D(u_tex, v_uv);
gl_FragColor = mix(base, texColor, u_use_tex);
}
"#,
),
GlBackend::Core33 => (
r#"#version 330 core
in vec3 a_pos;
in vec2 a_uv;
uniform mat4 u_mvp;
out vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = u_mvp * vec4(a_pos, 1.0);
}
"#,
r#"#version 330 core
uniform sampler2D u_tex;
uniform float u_use_tex;
in vec2 v_uv;
out vec4 fragColor;
void main() {
vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
vec4 texColor = texture(u_tex, v_uv);
fragColor = mix(base, texColor, u_use_tex);
}
"#,
),
};
let program = gl.create_program().map_err(|e| e.to_string())?;
let vs = gl
.create_shader(glow::VERTEX_SHADER)
.map_err(|e| e.to_string())?;
let fs = gl
.create_shader(glow::FRAGMENT_SHADER)
.map_err(|e| e.to_string())?;
gl.shader_source(vs, vs_src);
gl.compile_shader(vs);
if !gl.get_shader_compile_status(vs) {
let log = gl.get_shader_info_log(vs);
gl.delete_shader(vs);
gl.delete_shader(fs);
gl.delete_program(program);
return Err(format!("vertex shader compile failed: {log}"));
}
gl.shader_source(fs, fs_src);
gl.compile_shader(fs);
if !gl.get_shader_compile_status(fs) {
let log = gl.get_shader_info_log(fs);
gl.delete_shader(vs);
gl.delete_shader(fs);
gl.delete_program(program);
return Err(format!("fragment shader compile failed: {log}"));
}
gl.attach_shader(program, vs);
gl.attach_shader(program, fs);
gl.link_program(program);
gl.detach_shader(program, vs);
gl.detach_shader(program, fs);
gl.delete_shader(vs);
gl.delete_shader(fs);
if !gl.get_program_link_status(program) {
let log = gl.get_program_info_log(program);
gl.delete_program(program);
return Err(format!("program link failed: {log}"));
}
Ok(program)
}
fn model_matrix(position: [f32; 3], yaw: f32, scale: [f32; 3]) -> [f32; 16] {
let translation = mat4_translation(position[0], position[1], position[2]);
let rotation = mat4_rotation_y(yaw);
let scaling = mat4_scale(scale[0], scale[1], scale[2]);
let tr = mat4_mul(&translation, &rotation);
mat4_mul(&tr, &scaling)
}
fn camera_forward(yaw: f32, pitch: f32) -> [f32; 3] {
let cp = pitch.cos();
normalize3([yaw.sin() * cp, pitch.sin(), yaw.cos() * cp])
}
fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn normalize3(v: [f32; 3]) -> [f32; 3] {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if len <= 1e-6 {
[0.0, 0.0, 0.0]
} else {
[v[0] / len, v[1] / len, v[2] / len]
}
}
fn mat4_identity() -> [f32; 16] {
[
1.0, 0.0, 0.0, 0.0, //
0.0, 1.0, 0.0, 0.0, //
0.0, 0.0, 1.0, 0.0, //
0.0, 0.0, 0.0, 1.0, //
]
}
fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] {
let mut m = mat4_identity();
m[12] = x;
m[13] = y;
m[14] = z;
m
}
fn mat4_scale(x: f32, y: f32, z: f32) -> [f32; 16] {
[
x, 0.0, 0.0, 0.0, //
0.0, y, 0.0, 0.0, //
0.0, 0.0, z, 0.0, //
0.0, 0.0, 0.0, 1.0, //
]
}
fn mat4_rotation_y(rad: f32) -> [f32; 16] {
let c = rad.cos();
let s = rad.sin();
[
c, 0.0, -s, 0.0, //
0.0, 1.0, 0.0, 0.0, //
s, 0.0, c, 0.0, //
0.0, 0.0, 0.0, 1.0, //
]
}
fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
let f = 1.0 / (0.5 * fovy).tan();
let nf = 1.0 / (near - far);
[
f / aspect,
0.0,
0.0,
0.0,
0.0,
f,
0.0,
0.0,
0.0,
0.0,
(far + near) * nf,
-1.0,
0.0,
0.0,
(2.0 * far * near) * nf,
0.0,
]
}
fn mat4_look_at(eye: [f32; 3], target: [f32; 3], up: [f32; 3]) -> [f32; 16] {
let f = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
let s = normalize3(cross3(f, up));
let u = cross3(s, f);
[
s[0],
u[0],
-f[0],
0.0,
s[1],
u[1],
-f[1],
0.0,
s[2],
u[2],
-f[2],
0.0,
-dot3(s, eye),
-dot3(u, eye),
dot3(f, eye),
1.0,
]
}
fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
let mut out = [0.0f32; 16];
for c in 0..4 {
for r in 0..4 {
let mut acc = 0.0f32;
for k in 0..4 {
acc += a[k * 4 + r] * b[c * 4 + k];
}
out[c * 4 + r] = acc;
}
}
out
}
fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
for &value in slice {
out.extend_from_slice(&value.to_ne_bytes());
}
out
}
fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
for &value in slice {
out.extend_from_slice(&value.to_ne_bytes());
}
out
}