aboutsummaryrefslogtreecommitdiff
path: root/crates/render-demo
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/render-demo
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz
fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/render-demo')
-rw-r--r--crates/render-demo/Cargo.toml31
-rw-r--r--crates/render-demo/README.md84
-rw-r--r--crates/render-demo/build.rs4
-rw-r--r--crates/render-demo/src/lib.rs591
-rw-r--r--crates/render-demo/src/main.rs997
5 files changed, 0 insertions, 1707 deletions
diff --git a/crates/render-demo/Cargo.toml b/crates/render-demo/Cargo.toml
deleted file mode 100644
index a2161bb..0000000
--- a/crates/render-demo/Cargo.toml
+++ /dev/null
@@ -1,31 +0,0 @@
-[package]
-name = "render-demo"
-version = "0.1.0"
-edition = "2021"
-
-[features]
-default = []
-demo = ["dep:sdl2", "dep:glow", "dep:image"]
-
-[dependencies]
-encoding_rs = "0.8"
-msh-core = { path = "../msh-core" }
-nres = { path = "../nres" }
-render-core = { path = "../render-core" }
-texm = { path = "../texm" }
-glow = { version = "0.17", optional = true }
-image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
-
-[dev-dependencies]
-common = { path = "../common" }
-
-[target.'cfg(target_os = "macos")'.dependencies]
-sdl2 = { version = "0.38", optional = true, default-features = false, features = ["use-pkgconfig"] }
-
-[target.'cfg(not(target_os = "macos"))'.dependencies]
-sdl2 = { version = "0.38", optional = true, default-features = false, features = ["bundled", "static-link"] }
-
-[[bin]]
-name = "parkan-render-demo"
-path = "src/main.rs"
-required-features = ["demo"]
diff --git a/crates/render-demo/README.md b/crates/render-demo/README.md
deleted file mode 100644
index e9d5950..0000000
--- a/crates/render-demo/README.md
+++ /dev/null
@@ -1,84 +0,0 @@
-# render-demo
-
-Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL`: GLES2 с fallback на Core 3.3).
-
-## Назначение
-
-- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах.
-- Проверить текстурный path `WEAR -> MAT0 -> Texm` на реальных ассетах.
-- Служить минимальным reference-приложением.
-
-## Запуск
-
-```bash
-cargo run -p render-demo --features demo -- \
- --archive "testdata/Parkan - Iron Strategy/animals.rlb" \
- --model "A_L_01.msh" \
- --lod 0 \
- --group 0
-```
-
-### macOS prerequisites
-
-Для macOS `render-demo` ожидает системный SDL2 через `pkg-config`:
-
-```bash
-brew install sdl2 pkg-config
-```
-
-После этого запускайте той же командой `cargo run ... --features demo`.
-
-Параметры:
-
-- `--archive` (обязательный): NRes-архив с `.msh` entry.
-- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`.
-- `--lod` (опционально, default `0`).
-- `--group` (опционально, default `0`).
-- `--width`, `--height` (опционально, default `1280x720`).
-- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах).
-- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме.
-- В интерактивном режиме FPS выводится в заголовок окна и в stdout (обновление примерно каждые 0.5 сек).
-- `--texture <name>`: явное имя `Texm` (override авто-резолва).
-- `--texture-archive <path>`: путь к архиву текстур (по умолчанию `textures.lib` рядом с `--archive`).
-- `--material-archive <path>`: путь к `material.lib` (по умолчанию соседний `material.lib`).
-- `--wear <name.wea>`: имя wear-entry внутри модельного архива (по умолчанию `<model_stem>.wea`).
-- `--no-texture`: отключить текстуры и рендерить однотонным цветом.
-
-## Авто-резолв текстуры
-
-Если не передан `--texture`, демо пытается взять текстуру из игровых данных:
-
-1. `model.msh -> model.wea` (первый wear-материал),
-2. `material.lib` (`MAT0`) по имени материала с fallback `DEFAULT`,
-3. первая непустая `textureName` фаза материала,
-4. загрузка `Texm` из `textures.lib` (или `lightmap.lib` как fallback).
-
-## Детерминированный снимок кадра
-
-Для parity-проверок используется headless-сценарий с фиксированными параметрами:
-
-```bash
-cargo run -p render-demo --features demo -- \
- --archive "testdata/Parkan - Iron Strategy/animals.rlb" \
- --model "A_L_01.msh" \
- --lod 0 \
- --group 0 \
- --width 1280 \
- --height 720 \
- --angle 0.0 \
- --capture "target/render-parity/current/animals_a_l_01.png"
-```
-
-Явный выбор текстуры:
-
-```bash
-cargo run -p render-demo --features demo -- \
- --archive "testdata/Parkan - Iron Strategy/animals.rlb" \
- --model "A_L_01.msh" \
- --texture "PG09.0"
-```
-
-## Ограничения
-
-- Используется только базовая texture-фаза (без полной material/fx анимации).
-- Вывод через `glDrawElements(GL_TRIANGLES)` с index-buffer (позиции+UV).
diff --git a/crates/render-demo/build.rs b/crates/render-demo/build.rs
deleted file mode 100644
index 126d1d7..0000000
--- a/crates/render-demo/build.rs
+++ /dev/null
@@ -1,4 +0,0 @@
-fn main() {
- #[cfg(windows)]
- println!("cargo:rustc-link-lib=advapi32");
-}
diff --git a/crates/render-demo/src/lib.rs b/crates/render-demo/src/lib.rs
deleted file mode 100644
index 9555151..0000000
--- a/crates/render-demo/src/lib.rs
+++ /dev/null
@@ -1,591 +0,0 @@
-use encoding_rs::WINDOWS_1251;
-use msh_core::{parse_model_payload, Model};
-use nres::{Archive, EntryRef};
-use std::fmt;
-use std::path::{Path, PathBuf};
-use texm::{decode_mip_rgba8, parse_texm};
-
-const WEAR_KIND: u32 = 0x5241_4557;
-const MAT0_KIND: u32 = 0x3054_414D;
-
-#[derive(Debug)]
-pub enum Error {
- Nres(nres::error::Error),
- Msh(msh_core::error::Error),
- Texm(texm::error::Error),
- Io(std::io::Error),
- NoMshEntries,
- ModelNotFound(String),
- NoTexmEntries,
- TextureNotFound(String),
- MaterialNotFound(String),
- WearNotFound(String),
- InvalidWear(String),
- InvalidMaterial(String),
-}
-
-impl fmt::Display for Error {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Nres(err) => write!(f, "{err}"),
- Self::Msh(err) => write!(f, "{err}"),
- Self::Texm(err) => write!(f, "{err}"),
- Self::Io(err) => write!(f, "{err}"),
- Self::NoMshEntries => write!(f, "archive does not contain .msh entries"),
- Self::ModelNotFound(name) => write!(f, "model not found: {name}"),
- Self::NoTexmEntries => write!(f, "archive does not contain Texm entries"),
- Self::TextureNotFound(name) => write!(f, "texture not found: {name}"),
- Self::MaterialNotFound(name) => write!(f, "material not found: {name}"),
- Self::WearNotFound(name) => write!(f, "wear entry not found: {name}"),
- Self::InvalidWear(reason) => write!(f, "invalid WEAR payload: {reason}"),
- Self::InvalidMaterial(reason) => write!(f, "invalid MAT0 payload: {reason}"),
- }
- }
-}
-
-impl std::error::Error for Error {
- fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
- match self {
- Self::Nres(err) => Some(err),
- Self::Msh(err) => Some(err),
- Self::Texm(err) => Some(err),
- Self::Io(err) => Some(err),
- _ => None,
- }
- }
-}
-
-impl From<nres::error::Error> for Error {
- fn from(value: nres::error::Error) -> Self {
- Self::Nres(value)
- }
-}
-
-impl From<msh_core::error::Error> for Error {
- fn from(value: msh_core::error::Error) -> Self {
- Self::Msh(value)
- }
-}
-
-impl From<texm::error::Error> for Error {
- fn from(value: texm::error::Error) -> Self {
- Self::Texm(value)
- }
-}
-
-impl From<std::io::Error> for Error {
- fn from(value: std::io::Error) -> Self {
- Self::Io(value)
- }
-}
-
-pub type Result<T> = core::result::Result<T, Error>;
-
-#[derive(Clone, Debug)]
-pub struct LoadedModel {
- pub name: String,
- pub model: Model,
-}
-
-#[derive(Clone, Debug)]
-pub struct LoadedTexture {
- pub name: String,
- pub width: u32,
- pub height: u32,
- pub rgba8: Vec<u8>,
-}
-
-pub fn load_model_with_name_from_archive(
- path: &Path,
- model_name: Option<&str>,
-) -> Result<LoadedModel> {
- let archive = Archive::open_path(path)?;
- let mut msh_entries = Vec::new();
- for entry in archive.entries() {
- if entry.meta.name.to_ascii_lowercase().ends_with(".msh") {
- msh_entries.push((entry.id, entry.meta.name.clone()));
- }
- }
- if msh_entries.is_empty() {
- return Err(Error::NoMshEntries);
- }
-
- let target_id = if let Some(name) = model_name {
- msh_entries
- .iter()
- .find(|(_, n)| n.eq_ignore_ascii_case(name))
- .map(|(id, _)| *id)
- .ok_or_else(|| Error::ModelNotFound(name.to_string()))?
- } else {
- msh_entries[0].0
- };
-
- let target_name = archive
- .get(target_id)
- .map(|entry| entry.meta.name.clone())
- .unwrap_or_else(|| String::from("<unknown>"));
- let payload = archive.read(target_id)?;
- Ok(LoadedModel {
- name: target_name,
- model: parse_model_payload(payload.as_slice())?,
- })
-}
-
-pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result<Model> {
- Ok(load_model_with_name_from_archive(path, model_name)?.model)
-}
-
-pub fn load_texture_from_archive(path: &Path, texture_name: Option<&str>) -> Result<LoadedTexture> {
- let archive = Archive::open_path(path)?;
- if let Some(name) = texture_name {
- return load_texture_from_archive_by_name(&archive, name);
- }
-
- let mut texm_entries = archive
- .entries()
- .filter(|entry| entry.meta.kind == texm::TEXM_MAGIC)
- .collect::<Vec<_>>();
- if texm_entries.is_empty() {
- return Err(Error::NoTexmEntries);
- }
- texm_entries.sort_by(|a, b| {
- a.meta
- .name
- .to_ascii_lowercase()
- .cmp(&b.meta.name.to_ascii_lowercase())
- });
- let first = texm_entries[0];
- decode_texture_entry(&archive, first)
-}
-
-pub fn resolve_texture_for_model(
- model_archive_path: &Path,
- model_entry_name: &str,
- texture_name_override: Option<&str>,
- textures_archive_override: Option<&Path>,
- material_archive_override: Option<&Path>,
- wear_entry_override: Option<&str>,
-) -> Result<Option<LoadedTexture>> {
- if let Some(name) = texture_name_override {
- return load_texture_by_name_from_candidate_archives(
- name,
- candidate_texture_archives(model_archive_path, textures_archive_override),
- )
- .map(Some);
- }
-
- let wear_entry_name = if let Some(name) = wear_entry_override {
- name.to_string()
- } else {
- derive_wear_entry_name(model_entry_name).ok_or_else(|| {
- Error::WearNotFound(format!(
- "cannot derive WEAR name from model '{model_entry_name}'"
- ))
- })?
- };
-
- let model_archive = Archive::open_path(model_archive_path)?;
- let wear_materials = parse_wear_material_names(
- read_entry_by_name_kind(&model_archive, &wear_entry_name, WEAR_KIND)?
- .0
- .as_slice(),
- )?;
- let Some(primary_material) = wear_materials.first() else {
- return Ok(None);
- };
-
- let material_path = if let Some(path) = material_archive_override {
- path.to_path_buf()
- } else {
- sibling_archive_path(model_archive_path, "material.lib")
- .ok_or_else(|| Error::MaterialNotFound(String::from("material.lib")))?
- };
- let material_archive = Archive::open_path(&material_path)?;
- let material_entry = find_material_entry_with_fallback(&material_archive, primary_material)?;
- let material_payload = material_archive.read(material_entry.id)?.into_owned();
- let texture_name =
- parse_primary_texture_name_from_mat0(&material_payload, material_entry.meta.attr2)?;
- let Some(texture_name) = texture_name else {
- return Ok(None);
- };
-
- let texture = load_texture_by_name_from_candidate_archives(
- &texture_name,
- candidate_texture_archives(model_archive_path, textures_archive_override),
- )?;
- Ok(Some(texture))
-}
-
-fn load_texture_by_name_from_candidate_archives(
- texture_name: &str,
- archives: Vec<PathBuf>,
-) -> Result<LoadedTexture> {
- let mut last_not_found = None;
- for archive_path in archives {
- if !archive_path.is_file() {
- continue;
- }
- let archive = Archive::open_path(&archive_path)?;
- match load_texture_from_archive_by_name(&archive, texture_name) {
- Ok(texture) => return Ok(texture),
- Err(Error::TextureNotFound(name)) => {
- last_not_found = Some(name);
- }
- Err(other) => return Err(other),
- }
- }
-
- Err(Error::TextureNotFound(
- last_not_found.unwrap_or_else(|| texture_name.to_string()),
- ))
-}
-
-fn candidate_texture_archives(
- model_archive_path: &Path,
- textures_archive_override: Option<&Path>,
-) -> Vec<PathBuf> {
- if let Some(path) = textures_archive_override {
- return vec![path.to_path_buf()];
- }
-
- let mut out = Vec::new();
- if let Some(path) = sibling_archive_path(model_archive_path, "textures.lib") {
- out.push(path);
- }
- if let Some(path) = sibling_archive_path(model_archive_path, "lightmap.lib") {
- out.push(path);
- }
- out
-}
-
-fn sibling_archive_path(model_archive_path: &Path, name: &str) -> Option<PathBuf> {
- let parent = model_archive_path.parent()?;
- Some(parent.join(name))
-}
-
-fn derive_wear_entry_name(model_entry_name: &str) -> Option<String> {
- let stem = model_entry_name.rsplit_once('.').map(|(left, _)| left)?;
- Some(format!("{stem}.wea"))
-}
-
-fn read_entry_by_name_kind(
- archive: &Archive,
- name: &str,
- expected_kind: u32,
-) -> Result<(Vec<u8>, String)> {
- let Some(id) = archive.find(name) else {
- return Err(Error::WearNotFound(name.to_string()));
- };
- let Some(entry) = archive.get(id) else {
- return Err(Error::WearNotFound(name.to_string()));
- };
- if entry.meta.kind != expected_kind {
- return Err(Error::WearNotFound(name.to_string()));
- }
- let payload = archive.read(id)?.into_owned();
- Ok((payload, entry.meta.name.clone()))
-}
-
-fn find_material_entry_with_fallback<'a>(
- archive: &'a Archive,
- requested_name: &str,
-) -> Result<EntryRef<'a>> {
- if let Some(id) = archive.find(requested_name) {
- if let Some(entry) = archive.get(id) {
- if entry.meta.kind == MAT0_KIND {
- return Ok(entry);
- }
- }
- }
-
- if let Some(id) = archive.find("DEFAULT") {
- if let Some(entry) = archive.get(id) {
- if entry.meta.kind == MAT0_KIND {
- return Ok(entry);
- }
- }
- }
-
- let Some(entry) = archive.entries().find(|entry| entry.meta.kind == MAT0_KIND) else {
- return Err(Error::MaterialNotFound(requested_name.to_string()));
- };
- Ok(entry)
-}
-
-fn parse_wear_material_names(payload: &[u8]) -> Result<Vec<String>> {
- let text = decode_cp1251(payload).replace('\r', "");
- let mut lines = text.lines();
- let Some(first) = lines.next() else {
- return Err(Error::InvalidWear(String::from("WEAR payload is empty")));
- };
- let count = first
- .trim()
- .parse::<usize>()
- .map_err(|_| Error::InvalidWear(format!("invalid wearCount line: '{first}'")))?;
- if count == 0 {
- return Err(Error::InvalidWear(String::from("wearCount must be > 0")));
- }
-
- let mut materials = Vec::with_capacity(count);
- for idx in 0..count {
- let Some(line) = lines.next() else {
- return Err(Error::InvalidWear(format!(
- "missing material line {idx} of {count}"
- )));
- };
- let mut parts = line.split_whitespace();
- let _legacy = parts
- .next()
- .ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?;
- let name = parts
- .next()
- .ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?;
- materials.push(name.to_string());
- }
-
- Ok(materials)
-}
-
-fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
- if payload.len() < 4 {
- return Err(Error::InvalidMaterial(String::from(
- "MAT0 payload is too small for header",
- )));
- }
- 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
- .checked_add(2)
- .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
- }
- if attr2 >= 3 {
- offset = offset
- .checked_add(4)
- .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
- }
- if attr2 >= 4 {
- offset = offset
- .checked_add(4)
- .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
- }
-
- for phase in 0..phase_count {
- let phase_off = offset
- .checked_add(phase.checked_mul(34).ok_or_else(|| {
- Error::InvalidMaterial(String::from("MAT0 phase offset overflow"))
- })?)
- .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?;
- let phase_end = phase_off
- .checked_add(34)
- .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?;
- let Some(rec) = payload.get(phase_off..phase_end) else {
- return Err(Error::InvalidMaterial(format!(
- "MAT0 phase {phase} is out of bounds"
- )));
- };
- let name_raw = &rec[18..34];
- let name_end = name_raw
- .iter()
- .position(|&b| b == 0)
- .unwrap_or(name_raw.len());
- let name = decode_cp1251(&name_raw[..name_end]).trim().to_string();
- if !name.is_empty() {
- return Ok(Some(name));
- }
- }
-
- Ok(None)
-}
-
-fn decode_cp1251(bytes: &[u8]) -> String {
- let (decoded, _, _) = WINDOWS_1251.decode(bytes);
- decoded.into_owned()
-}
-
-fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result<LoadedTexture> {
- let Some(id) = archive.find(name) else {
- return Err(Error::TextureNotFound(name.to_string()));
- };
- let Some(entry) = archive.get(id) else {
- return Err(Error::TextureNotFound(name.to_string()));
- };
- if entry.meta.kind != texm::TEXM_MAGIC {
- return Err(Error::TextureNotFound(name.to_string()));
- }
- decode_texture_entry(archive, entry)
-}
-
-fn decode_texture_entry(archive: &Archive, entry: EntryRef<'_>) -> Result<LoadedTexture> {
- let payload = archive.read(entry.id)?.into_owned();
- let parsed = parse_texm(&payload)?;
- let decoded = decode_mip_rgba8(&parsed, &payload, 0)?;
- Ok(LoadedTexture {
- name: entry.meta.name.clone(),
- width: decoded.width,
- height: decoded.height,
- rgba8: decoded.rgba8,
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use common::collect_files_recursive;
- use std::fs;
- use std::path::{Path, PathBuf};
-
- fn archive_with_msh() -> Option<PathBuf> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata");
- let mut files = Vec::new();
- collect_files_recursive(&root, &mut files);
- files.sort();
- for path in files {
- let Ok(bytes) = fs::read(&path) else {
- continue;
- };
- if bytes.get(0..4) != Some(b"NRes") {
- continue;
- }
- let Ok(archive) = Archive::open_path(&path) else {
- continue;
- };
- if archive
- .entries()
- .any(|entry| entry.meta.name.to_ascii_lowercase().ends_with(".msh"))
- {
- return Some(path);
- }
- }
- None
- }
-
- fn game_root() -> Option<PathBuf> {
- let path = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata")
- .join("Parkan - Iron Strategy");
- if path.is_dir() {
- Some(path)
- } else {
- None
- }
- }
-
- #[test]
- fn load_model_from_real_archive() {
- let Some(path) = archive_with_msh() else {
- eprintln!("skipping load_model_from_real_archive: no .msh archives in testdata");
- return;
- };
- let model = load_model_from_archive(&path, None)
- .unwrap_or_else(|err| panic!("failed to load model from {}: {err:?}", path.display()));
- assert!(model.node_count > 0);
- assert!(!model.positions.is_empty());
- assert!(!model.indices.is_empty());
- }
-
- #[test]
- fn resolve_texture_for_real_model_via_wear_and_material() {
- let Some(root) = game_root() else {
- eprintln!(
- "skipping resolve_texture_for_real_model_via_wear_and_material: no game root"
- );
- return;
- };
- let archive = root.join("animals.rlb");
- if !archive.is_file() {
- eprintln!("skipping resolve_texture_for_real_model_via_wear_and_material: missing animals.rlb");
- return;
- }
-
- let loaded = load_model_with_name_from_archive(&archive, Some("A_L_01.msh"))
- .unwrap_or_else(|err| {
- panic!(
- "failed to load model A_L_01.msh from {}: {err:?}",
- archive.display()
- )
- });
- let texture = resolve_texture_for_model(&archive, &loaded.name, None, None, None, None)
- .unwrap_or_else(|err| panic!("failed to resolve texture for {}: {err:?}", loaded.name))
- .expect("texture must be resolved for A_L_01.msh");
- assert!(texture.width > 0 && texture.height > 0);
- assert_eq!(
- texture.rgba8.len(),
- usize::try_from(texture.width)
- .ok()
- .and_then(|w| usize::try_from(texture.height).ok().map(|h| w * h * 4))
- .unwrap_or(0)
- );
- }
-
- #[test]
- fn load_first_texture_from_real_archive() {
- let Some(root) = game_root() else {
- eprintln!("skipping load_first_texture_from_real_archive: no game root");
- return;
- };
- let archive = root.join("textures.lib");
- if !archive.is_file() {
- eprintln!("skipping load_first_texture_from_real_archive: missing textures.lib");
- return;
- }
- let texture = load_texture_from_archive(&archive, None).unwrap_or_else(|err| {
- panic!(
- "failed to load first texture from {}: {err:?}",
- archive.display()
- )
- });
- assert!(texture.width > 0 && texture.height > 0);
- assert!(!texture.rgba8.is_empty());
- }
-
- #[test]
- fn parse_wear_material_names_parses_counted_lines() {
- let payload = b"2\r\n0 MAT_A\r\n1 MAT_B\r\n";
- let materials =
- parse_wear_material_names(payload).expect("failed to parse valid WEAR payload");
- assert_eq!(materials, vec!["MAT_A".to_string(), "MAT_B".to_string()]);
- }
-
- #[test]
- fn parse_wear_material_names_rejects_invalid_payload() {
- let payload = b"2\n0 ONLY_ONE\n";
- assert!(matches!(
- parse_wear_material_names(payload),
- Err(Error::InvalidWear(_))
- ));
- }
-
- #[test]
- fn parse_primary_texture_name_from_mat0_respects_attr2_layout() {
- let mut payload = vec![0u8; 4 + 10 + 34];
- payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
- // attr2=4 adds 10 bytes before phase table
- let name = b"TEX_MAIN";
- payload[4 + 10 + 18..4 + 10 + 18 + name.len()].copy_from_slice(name);
-
- let parsed = parse_primary_texture_name_from_mat0(&payload, 4)
- .expect("failed to parse MAT0 payload with attr2=4");
- assert_eq!(parsed, Some("TEX_MAIN".to_string()));
- }
-
- #[test]
- fn parse_primary_texture_name_from_mat0_decodes_cp1251_bytes() {
- let mut payload = vec![0u8; 4 + 34];
- payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
- payload[4 + 18] = 0xC0; // 'А' in CP1251
-
- let parsed =
- parse_primary_texture_name_from_mat0(&payload, 0).expect("failed to parse MAT0");
- assert_eq!(parsed, Some("А".to_string()));
- }
-}
diff --git a/crates/render-demo/src/main.rs b/crates/render-demo/src/main.rs
deleted file mode 100644
index 61f6bfa..0000000
--- a/crates/render-demo/src/main.rs
+++ /dev/null
@@ -1,997 +0,0 @@
-use glow::HasContext as _;
-use render_core::{build_render_mesh, compute_bounds_for_mesh};
-use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
-use std::io::Write as _;
-use std::path::{Path, PathBuf};
-use std::time::{Duration, Instant};
-
-struct Args {
- archive: PathBuf,
- model: Option<String>,
- lod: usize,
- group: usize,
- width: u32,
- height: u32,
- fov_deg: f32,
- capture: Option<PathBuf>,
- angle: Option<f32>,
- spin_rate: f32,
- texture: Option<String>,
- texture_archive: Option<PathBuf>,
- material_archive: Option<PathBuf>,
- wear: Option<String>,
- no_texture: bool,
-}
-
-struct GpuTexture {
- handle: glow::NativeTexture,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
-enum GlBackend {
- Gles2,
- Core33,
-}
-
-fn parse_args() -> Result<Args, String> {
- let mut archive = None;
- let mut model = None;
- let mut lod = 0usize;
- let mut group = 0usize;
- let mut width = 1280u32;
- let mut height = 720u32;
- let mut fov_deg = 60.0f32;
- let mut capture = None;
- let mut angle = None;
- let mut spin_rate = 0.35f32;
- let mut texture = None;
- let mut texture_archive = None;
- let mut material_archive = None;
- let mut wear = None;
- let mut no_texture = false;
-
- let mut it = std::env::args().skip(1);
- while let Some(arg) = it.next() {
- match arg.as_str() {
- "--archive" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --archive"))?;
- archive = Some(PathBuf::from(value));
- }
- "--model" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --model"))?;
- model = Some(value);
- }
- "--lod" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --lod"))?;
- lod = value
- .parse::<usize>()
- .map_err(|_| String::from("invalid --lod value"))?;
- }
- "--group" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --group"))?;
- group = value
- .parse::<usize>()
- .map_err(|_| String::from("invalid --group 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]"));
- }
- }
- "--capture" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --capture"))?;
- capture = Some(PathBuf::from(value));
- }
- "--angle" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --angle"))?;
- angle = Some(
- value
- .parse::<f32>()
- .map_err(|_| String::from("invalid --angle value"))?,
- );
- }
- "--spin-rate" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --spin-rate"))?;
- spin_rate = value
- .parse::<f32>()
- .map_err(|_| String::from("invalid --spin-rate value"))?;
- }
- "--texture" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --texture"))?;
- texture = Some(value);
- }
- "--texture-archive" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --texture-archive"))?;
- texture_archive = Some(PathBuf::from(value));
- }
- "--material-archive" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --material-archive"))?;
- material_archive = Some(PathBuf::from(value));
- }
- "--wear" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --wear"))?;
- wear = Some(value);
- }
- "--no-texture" => {
- no_texture = true;
- }
- "--help" | "-h" => {
- print_help();
- std::process::exit(0);
- }
- other => {
- return Err(format!("unknown argument: {other}"));
- }
- }
- }
-
- let archive = archive.ok_or_else(|| String::from("missing required --archive"))?;
- Ok(Args {
- archive,
- model,
- lod,
- group,
- width,
- height,
- fov_deg,
- capture,
- angle,
- spin_rate,
- texture,
- texture_archive,
- material_archive,
- wear,
- no_texture,
- })
-}
-
-fn print_help() {
- eprintln!(
- "parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H] [--fov DEG]"
- );
- eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
- eprintln!(" [--texture <name>] [--texture-archive <path>] [--material-archive <path>] [--wear <name.wea>] [--no-texture]");
-}
-
-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 loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref())
- .map_err(|err| {
- format!(
- "failed to load model from archive {}: {err}",
- args.archive.display()
- )
- })?;
- let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group);
- if mesh.indices.is_empty() {
- return Err(format!(
- "model has no renderable triangles for lod={} group={}",
- args.lod, args.group
- ));
- }
- if mesh.index_overflow {
- eprintln!(
- "warning: mesh exceeds u16 index space and may be partially rendered on GLES2 targets"
- );
- }
- let Some((bounds_min, bounds_max)) = compute_bounds_for_mesh(&mesh.vertices) else {
- return Err(String::from("failed to compute mesh bounds"));
- };
-
- let resolved_texture = resolve_texture(&args, &loaded_model.name)?;
- if let Some(tex) = resolved_texture.as_ref() {
- println!(
- "resolved texture '{}' ({}x{})",
- tex.name, tex.width, tex.height
- );
- } else {
- println!("texture path disabled or unresolved; rendering with fallback color");
- }
-
- let center = [
- 0.5 * (bounds_min[0] + bounds_max[0]),
- 0.5 * (bounds_min[1] + bounds_max[1]),
- 0.5 * (bounds_min[2] + bounds_max[2]),
- ];
- let extent = [
- bounds_max[0] - bounds_min[0],
- bounds_max[1] - bounds_min[1],
- bounds_max[2] - bounds_min[2],
- ];
- let radius =
- (extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt() * 0.5;
- let camera_distance = (radius * 2.5).max(2.0);
-
- 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)?;
- let _ = if args.capture.is_some() {
- video.gl_set_swap_interval(0)
- } else {
- video.gl_set_swap_interval(1)
- };
-
- 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]);
- }
- let vertex_bytes = f32_slice_to_ne_bytes(&vertex_data);
- let index_bytes = u16_slice_to_ne_bytes(&mesh.indices);
-
- 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 vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
- let ebo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
- unsafe {
- 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 vao = unsafe { create_vertex_layout_if_needed(&gl, gl_backend, vbo, ebo, a_pos, a_uv)? };
-
- let gpu_texture = if let Some(texture) = resolved_texture.as_ref() {
- Some(unsafe { create_texture(&gl, texture)? })
- } else {
- None
- };
-
- let result = if let Some(capture_path) = args.capture.as_ref() {
- run_capture(
- &gl,
- program,
- u_mvp.as_ref(),
- u_use_tex.as_ref(),
- u_tex.as_ref(),
- a_pos,
- a_uv,
- vbo,
- ebo,
- vao,
- gpu_texture.as_ref(),
- mesh.indices.len(),
- &args,
- center,
- camera_distance,
- capture_path,
- )
- } else {
- run_interactive(
- &sdl,
- &mut window,
- &gl,
- program,
- u_mvp.as_ref(),
- u_use_tex.as_ref(),
- u_tex.as_ref(),
- a_pos,
- a_uv,
- vbo,
- ebo,
- vao,
- gpu_texture.as_ref(),
- mesh.indices.len(),
- &args,
- center,
- camera_distance,
- )
- };
-
- unsafe {
- if let Some(texture) = gpu_texture {
- gl.delete_texture(texture.handle);
- }
- if let Some(vao) = vao {
- gl.delete_vertex_array(vao);
- }
- gl.delete_buffer(ebo);
- gl.delete_buffer(vbo);
- gl.delete_program(program);
- }
-
- result
-}
-
-fn create_window_and_context(
- video: &sdl2::VideoSubsystem,
- args: &Args,
-) -> 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 Render Demo (SDL2 + OpenGL)",
- args.width,
- args.height,
- );
- window_builder.opengl();
- if args.capture.is_some() {
- window_builder.hidden();
- } else {
- window_builder.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_vertex_layout_if_needed(
- gl: &glow::Context,
- backend: GlBackend,
- vbo: glow::NativeBuffer,
- ebo: glow::NativeBuffer,
- a_pos: u32,
- a_uv: u32,
-) -> Result<Option<glow::NativeVertexArray>, String> {
- if backend != GlBackend::Core33 {
- return Ok(None);
- }
-
- let vao = gl.create_vertex_array().map_err(|e| e.to_string())?;
- gl.bind_vertex_array(Some(vao));
- gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
- gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(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.bind_vertex_array(None);
- Ok(Some(vao))
-}
-
-fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture>, String> {
- if args.no_texture {
- return Ok(None);
- }
-
- match resolve_texture_for_model(
- &args.archive,
- model_name,
- args.texture.as_deref(),
- args.texture_archive.as_deref(),
- args.material_archive.as_deref(),
- args.wear.as_deref(),
- ) {
- Ok(texture) => Ok(texture),
- Err(err) => {
- if args.texture.is_some()
- || args.texture_archive.is_some()
- || args.material_archive.is_some()
- || args.wear.is_some()
- {
- Err(format!("failed to resolve texture: {err}"))
- } else {
- eprintln!("warning: auto texture resolve failed ({err}), fallback to solid color");
- Ok(None)
- }
- }
- }
-}
-
-unsafe fn create_texture(
- gl: &glow::Context,
- texture: &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 })
-}
-
-#[allow(clippy::too_many_arguments)]
-fn run_capture(
- 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,
- vbo: glow::NativeBuffer,
- ebo: glow::NativeBuffer,
- vao: Option<glow::NativeVertexArray>,
- texture: Option<&GpuTexture>,
- index_count: usize,
- args: &Args,
- center: [f32; 3],
- camera_distance: f32,
- capture_path: &Path,
-) -> Result<(), String> {
- let angle = args.angle.unwrap_or(0.0);
- let mvp = compute_mvp(
- args.width,
- args.height,
- args.fov_deg,
- center,
- camera_distance,
- angle,
- );
- unsafe {
- draw_frame(
- gl,
- program,
- u_mvp,
- u_use_tex,
- u_tex,
- a_pos,
- a_uv,
- vbo,
- ebo,
- vao,
- texture,
- index_count,
- args.width,
- args.height,
- &mvp,
- );
- }
- let mut rgba = unsafe { read_pixels_rgba(gl, args.width, args.height)? };
- flip_image_y_rgba(&mut rgba, args.width as usize, args.height as usize);
- save_png(capture_path, args.width, args.height, rgba)?;
- println!("captured frame to {}", capture_path.display());
- Ok(())
-}
-
-#[allow(clippy::too_many_arguments)]
-fn run_interactive(
- sdl: &sdl2::Sdl,
- window: &mut sdl2::video::Window,
- 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,
- vbo: glow::NativeBuffer,
- ebo: glow::NativeBuffer,
- vao: Option<glow::NativeVertexArray>,
- texture: Option<&GpuTexture>,
- index_count: usize,
- args: &Args,
- center: [f32; 3],
- camera_distance: f32,
-) -> Result<(), String> {
- let mut events = sdl
- .event_pump()
- .map_err(|err| format!("failed to get SDL event pump: {err}"))?;
- let start = Instant::now();
- let mut fps_window_start = Instant::now();
- let mut fps_frames: u32 = 0;
- let mut fps_printed = false;
- let base_title = "Parkan Render Demo (SDL2 + OpenGL)";
-
- '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,
- _ => {}
- }
- }
-
- let (w, h) = window.size();
- let angle = args
- .angle
- .unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate);
- let mvp = compute_mvp(w, h, args.fov_deg, center, camera_distance, angle);
-
- unsafe {
- draw_frame(
- gl,
- program,
- u_mvp,
- u_use_tex,
- u_tex,
- a_pos,
- a_uv,
- vbo,
- ebo,
- vao,
- texture,
- index_count,
- w,
- h,
- &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!(
- "{base_title} | FPS: {fps:.1} ({frame_time_ms:.2} ms)"
- ));
- 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!();
- }
-
- Ok(())
-}
-
-fn compute_mvp(
- width: u32,
- height: u32,
- fov_deg: f32,
- center: [f32; 3],
- camera_distance: f32,
- angle_rad: f32,
-) -> [f32; 16] {
- let aspect = (width as f32 / (height.max(1) as f32)).max(0.01);
- let proj = mat4_perspective(fov_deg.to_radians(), aspect, 0.01, camera_distance * 10.0);
- let view = mat4_translation(0.0, 0.0, -camera_distance);
- let center_shift = mat4_translation(-center[0], -center[1], -center[2]);
- let rot = mat4_rotation_y(angle_rad);
- let model_m = mat4_mul(&rot, &center_shift);
- let vp = mat4_mul(&view, &model_m);
- mat4_mul(&proj, &vp)
-}
-
-#[allow(clippy::too_many_arguments)]
-unsafe fn draw_frame(
- 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,
- vbo: glow::NativeBuffer,
- ebo: glow::NativeBuffer,
- vao: Option<glow::NativeVertexArray>,
- texture: Option<&GpuTexture>,
- index_count: usize,
- width: u32,
- height: u32,
- mvp: &[f32; 16],
-) {
- 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);
-
- gl.use_program(Some(program));
- gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
-
- let texture_enabled = texture.is_some();
- gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
- if let Some(tex) = 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);
- }
-
- if let Some(vao) = vao {
- gl.bind_vertex_array(Some(vao));
- gl.draw_elements(
- glow::TRIANGLES,
- index_count.min(i32::MAX as usize) as i32,
- glow::UNSIGNED_SHORT,
- 0,
- );
- gl.bind_vertex_array(None);
- } else {
- gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
- gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(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,
- 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);
-}
-
-unsafe fn read_pixels_rgba(gl: &glow::Context, width: u32, height: u32) -> Result<Vec<u8>, String> {
- let pixel_count = usize::try_from(width)
- .ok()
- .and_then(|w| usize::try_from(height).ok().map(|h| w.saturating_mul(h)))
- .ok_or_else(|| String::from("frame dimensions are too large"))?;
- let mut pixels = vec![0u8; pixel_count.saturating_mul(4)];
- gl.read_pixels(
- 0,
- 0,
- width.min(i32::MAX as u32) as i32,
- height.min(i32::MAX as u32) as i32,
- glow::RGBA,
- glow::UNSIGNED_BYTE,
- glow::PixelPackData::Slice(Some(pixels.as_mut_slice())),
- );
- Ok(pixels)
-}
-
-fn flip_image_y_rgba(rgba: &mut [u8], width: usize, height: usize) {
- let stride = width.saturating_mul(4);
- if stride == 0 {
- return;
- }
- for y in 0..(height / 2) {
- let top = y * stride;
- let bottom = (height - 1 - y) * stride;
- for i in 0..stride {
- rgba.swap(top + i, bottom + i);
- }
- }
-}
-
-fn save_png(path: &Path, width: u32, height: u32, rgba: Vec<u8>) -> Result<(), String> {
- if let Some(parent) = path.parent() {
- if !parent.as_os_str().is_empty() {
- std::fs::create_dir_all(parent).map_err(|err| {
- format!(
- "failed to create output directory {}: {err}",
- parent.display()
- )
- })?;
- }
- }
- let image = image::RgbaImage::from_raw(width, height, rgba)
- .ok_or_else(|| String::from("failed to build image from framebuffer bytes"))?;
- image
- .save(path)
- .map_err(|err| format!("failed to save PNG {}: {err}", path.display()))
-}
-
-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.85, 0.90, 1.00, 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.85, 0.90, 1.00, 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 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
-}
-
-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_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_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
-}