scena 1.1.0

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use crate::assets::AssetPath;
use crate::diagnostics::AssetError;
use crate::material::TextureColorSpace;

#[cfg(feature = "ktx2")]
use super::TextureMipLevel;
use super::TexturePixels;

pub(super) fn ktx2_descriptor_only_error(path: &AssetPath) -> AssetError {
    AssetError::UnsupportedOptionalExtensionUsed {
        path: path.as_str().to_string(),
        extension: "KHR_texture_basisu".to_string(),
        help: "enable a decoder-backed ktx2 path and provide decodable KTX2/Basis bytes; \
               descriptor-only KTX2 textures are not supported"
            .to_string(),
    }
}

pub(super) fn decode_ktx2_basisu_rgba8(
    path: &AssetPath,
    bytes: &[u8],
    color_space: TextureColorSpace,
) -> Result<TexturePixels, AssetError> {
    #[cfg(feature = "ktx2")]
    {
        decode_ktx2_basisu_rgba8_with_parser(path, bytes, color_space)
    }
    #[cfg(not(feature = "ktx2"))]
    {
        let _ = bytes;
        let _ = color_space;
        Err(ktx2_descriptor_only_error(path))
    }
}

#[cfg(feature = "ktx2")]
fn decode_ktx2_basisu_rgba8_with_parser(
    path: &AssetPath,
    bytes: &[u8],
    color_space: TextureColorSpace,
) -> Result<TexturePixels, AssetError> {
    #[cfg(all(
        target_arch = "wasm32",
        target_vendor = "unknown",
        target_os = "unknown"
    ))]
    {
        let _ = bytes;
        return Err(AssetError::Parse {
            path: path.as_str().to_string(),
            reason:
                "KTX2/Basis transcoding requires async Basis Universal initialization on wasm; \
                     this sync texture decode path is fail-closed until the browser asset pipeline \
                     can await transcoder initialization"
                    .to_string(),
        });
    }

    let reader = ktx2::Reader::new(bytes).map_err(|error| AssetError::Parse {
        path: path.as_str().to_string(),
        reason: format!("invalid KTX2 container: {error:?}"),
    })?;
    let header = reader.header();
    if header.pixel_depth > 0 || header.face_count > 1 || header.layer_count > 1 {
        return Err(AssetError::Parse {
            path: path.as_str().to_string(),
            reason: "only 2D, single-layer KTX2/Basis textures can be decoded into TexturePixels"
                .to_string(),
        });
    }

    #[cfg(not(all(
        target_arch = "wasm32",
        target_vendor = "unknown",
        target_os = "unknown"
    )))]
    {
        use basisu_c_sys::TranscodeTargetFormat;
        use basisu_c_sys::extra::{
            BasisuTranscoder, ChannelType, SupportedTextureCompression, basisu_transcoder_init,
        };

        pollster::block_on(basisu_transcoder_init());
        let transcoder = BasisuTranscoder::new(
            bytes,
            SupportedTextureCompression::empty(),
            ChannelType::Rgba,
        )
        .map_err(|error| AssetError::Parse {
            path: path.as_str().to_string(),
            reason: format!("failed to initialize KTX2/Basis transcoder: {error}"),
        })?;
        let info = transcoder.get_info();
        let encoded_color_space = if info.is_srgb {
            TextureColorSpace::Srgb
        } else {
            TextureColorSpace::Linear
        };
        if encoded_color_space != color_space {
            return Err(AssetError::Parse {
                path: path.as_str().to_string(),
                reason: format!(
                    "KTX2/Basis color-space mismatch: texture is authored as {encoded_color_space:?} but was requested as {color_space:?}"
                ),
            });
        }
        if info.faces != 1 || info.layers > 1 {
            return Err(AssetError::Parse {
                path: path.as_str().to_string(),
                reason: format!(
                    "KTX2/Basis texture is not a single 2D image: faces={}, layers={}",
                    info.faces, info.layers
                ),
            });
        }
        let image = transcoder
            .transcode(Some(TranscodeTargetFormat::RGBA32), Some(info.is_srgb))
            .map_err(|error| AssetError::Parse {
                path: path.as_str().to_string(),
                reason: format!("failed to transcode KTX2/Basis texture to RGBA8: {error}"),
            })?;
        if !format!("{:?}", image.format).starts_with("Rgba8Unorm") {
            return Err(AssetError::Parse {
                path: path.as_str().to_string(),
                reason: format!(
                    "KTX2/Basis transcoder returned unsupported CPU texture format {:?}",
                    image.format
                ),
            });
        }
        let width = info.width.max(1);
        let height = info.height.max(1);
        let base_level_len = checked_rgba8_len(path, width, height)?;
        if image.data.len() < base_level_len {
            return Err(AssetError::Parse {
                path: path.as_str().to_string(),
                reason: format!(
                    "KTX2/Basis transcoder returned {} byte(s), expected at least {base_level_len}",
                    image.data.len()
                ),
            });
        }
        TexturePixels::from_mip_levels(
            path,
            decoded_ktx2_rgba8_mip_levels(path, width, height, info.levels, &image.data)?,
        )
    }
}

#[cfg(feature = "ktx2")]
fn decoded_ktx2_rgba8_mip_levels(
    path: &AssetPath,
    width: u32,
    height: u32,
    level_count: u32,
    data: &[u8],
) -> Result<Vec<TextureMipLevel>, AssetError> {
    if width == 0 || height == 0 {
        return Err(AssetError::Parse {
            path: path.as_str().to_string(),
            reason: format!("KTX2/Basis texture has invalid base dimensions {width}x{height}"),
        });
    }
    if level_count == 0 {
        return Err(AssetError::Parse {
            path: path.as_str().to_string(),
            reason: "KTX2/Basis texture has zero mip levels".to_string(),
        });
    }
    let mut levels = Vec::with_capacity(level_count as usize);
    let mut offset = 0usize;
    for level_index in 0..level_count {
        let level_width = (width >> level_index).max(1);
        let level_height = (height >> level_index).max(1);
        let level_len = checked_rgba8_len(path, level_width, level_height)?;
        let end = offset
            .checked_add(level_len)
            .ok_or_else(|| AssetError::Parse {
                path: path.as_str().to_string(),
                reason: "KTX2/Basis decoded mip byte offsets overflowed".to_string(),
            })?;
        let Some(level_bytes) = data.get(offset..end) else {
            return Err(AssetError::Parse {
                path: path.as_str().to_string(),
                reason: format!(
                    "KTX2/Basis transcoder returned truncated mip level {level_index}: \
                     need bytes {offset}..{end}, got {}",
                    data.len()
                ),
            });
        };
        levels.push(TextureMipLevel {
            width: level_width,
            height: level_height,
            rgba8: level_bytes.to_vec(),
        });
        offset = end;
    }
    Ok(levels)
}

#[cfg(feature = "ktx2")]
fn checked_rgba8_len(path: &AssetPath, width: u32, height: u32) -> Result<usize, AssetError> {
    let pixels = u64::from(width)
        .checked_mul(u64::from(height))
        .and_then(|pixels| pixels.checked_mul(4))
        .ok_or_else(|| AssetError::Parse {
            path: path.as_str().to_string(),
            reason: format!("texture dimensions {width}x{height} overflow RGBA8 byte length"),
        })?;
    usize::try_from(pixels).map_err(|_| AssetError::Parse {
        path: path.as_str().to_string(),
        reason: format!("texture dimensions {width}x{height} exceed platform address space"),
    })
}

#[cfg(feature = "ktx2")]
pub(super) fn validate_rgba8_payload_len(
    path: &AssetPath,
    width: u32,
    height: u32,
    actual_len: usize,
) -> Result<(), AssetError> {
    if width == 0 || height == 0 {
        return Err(AssetError::Parse {
            path: path.as_str().to_string(),
            reason: format!("texture level has invalid dimensions {width}x{height}"),
        });
    }
    let expected_len = u64::from(width)
        .checked_mul(u64::from(height))
        .and_then(|pixels| pixels.checked_mul(4))
        .ok_or_else(|| AssetError::Parse {
            path: path.as_str().to_string(),
            reason: format!("texture dimensions {width}x{height} overflow RGBA8 byte length"),
        })?;
    if u64::try_from(actual_len).ok() != Some(expected_len) {
        return Err(AssetError::Parse {
            path: path.as_str().to_string(),
            reason: format!(
                "texture RGBA8 payload length mismatch for {width}x{height}: \
                 got {actual_len}, expected {expected_len}"
            ),
        });
    }
    Ok(())
}