Zo schrijf je je eigen puntenwolk viewer met Rust en macroquad

Ik wilde al lang mijn eigen point cloud viewer bouwen. CloudCompare is mijn dagelijkse tool, maar ik wilde ook iets dat ik zelf kan uitbreiden en waarin ik algoritmes kan draaien. Eerder heb ik naar Unity, Godot en Unreal Engine gekeken, maar een volwaardige game-engine is meer dan ik nodig heb, en ze zijn behoorlijk ingewikkeld om in te programmeren. Bovendien ben ik inmiddels voor een groot deel van mijn software-ontwikkeling rond point clouds overgestapt op Rust. Bevy zou een voor de hand liggende keuze zijn, en daar wil ik me nog verder in verdiepen. Maar voor nu heb ik besloten om iets nóg lichtgewichters en low-levels te proberen: macroquad. Het is meer gericht op 2D, maar heeft een 3D-camera, en dat is eigenlijk alles wat je nodig hebt – alle game-engines moeten tenslotte 3D-objecten op een plat vlak projecteren.

Ik liep echter snel tegen problemen aan toen ik veel punten als bollen probeerde te tekenen. Dat presteerde gewoon helemaal niet. De oplossing was een niveau dieper gaan en miniquad gebruiken om direct vertex buffers aan te sturen. Hiervoor is erg weinig documentatie en zijn er maar een paar voorbeeldprojecten, dus uiteindelijk heb ik Claude Code ingeschakeld om me te helpen. Het resultaat was een werkende viewer in minder dan 600 regels code.

Hier zijn twee screenshots van een point cloud, weergegeven op hoogte en op kleur:

De code

Hier is het eerste deel, de macroquad-code:

use std::env;

use console::style;
use crate::glam::vec3;

use macroquad::prelude::*;

const MOVE_SPEED: f32 = 0.5;
const LOOK_SPEED: f32 = 0.5;

#[derive(Clone, Copy, PartialEq)]
pub enum ColorMode {
    Height,
    Intensity,
    Classification,
    Rgb,
}

impl ColorMode {
    fn label(&self) -> &'static str {
        match self {
            ColorMode::Height => "Height",
            ColorMode::Intensity => "Intensity",
            ColorMode::Classification => "Classification",
            ColorMode::Rgb => "RGB",
        }
    }
}

const COLOR_MODES: [ColorMode; 4] = [
    ColorMode::Height,
    ColorMode::Intensity,
    ColorMode::Classification,
    ColorMode::Rgb,
];

#[macroquad::main("Hai Performance Point Cloud Viewer")]
async fn main() {
    if env::args().count() != 2 {
        println!("{}", style("Usage: viewer pointcloud.laz").red());
        return;
    }
    let args: Vec<String> = env::args().collect();
    let cloudfile = &args[1];

    let mut color_mode = ColorMode::Height;

    let mut stage = {
        let InternalGlContext {
            quad_context: ctx, ..
        } = unsafe { get_internal_gl() };

        raw_miniquad::Stage::new(ctx, cloudfile, color_mode)
    };

    let world_up = vec3(0.0, 1.0, 0.0);
    let mut yaw: f32 = 1.18;
    let mut pitch: f32 = 0.0;

    let mut front = vec3(
        yaw.cos() * pitch.cos(),
        pitch.sin(),
        yaw.sin() * pitch.cos(),
    )
    .normalize();
    let mut right = front.cross(world_up).normalize();
    let mut up = right.cross(front).normalize();
    let mut position = vec3(0.0, 1.0, 0.0);
    let mut last_mouse_position: Vec2 = mouse_position().into();

    let mut grabbed = false;
    set_cursor_grab(grabbed);
    show_mouse(!grabbed);

    // Orthographic zoom: visible half-height in world units
    let mut ortho_scale: f32 = 50.0;

    loop {
        let delta = get_frame_time();

        if is_key_pressed(KeyCode::Escape) {
            break;
        }
        if is_key_pressed(KeyCode::Tab) {
            grabbed = !grabbed;
            set_cursor_grab(grabbed);
            show_mouse(!grabbed);
        }

        if is_key_down(KeyCode::Up) || is_key_down(KeyCode::W) {
            position += up * MOVE_SPEED;
        }
        if is_key_down(KeyCode::Down) || is_key_down(KeyCode::S) {
            position -= up * MOVE_SPEED;
        }
        if is_key_down(KeyCode::Left) || is_key_down(KeyCode::A) {
            position -= right * MOVE_SPEED;
        }
        if is_key_down(KeyCode::Right) || is_key_down(KeyCode::D) {
            position += right * MOVE_SPEED;
        }

        let (_mouse_wheel_x, mouse_wheel_y) = mouse_wheel();
        if mouse_wheel_y.abs() > 0.001 {
            let zoom_factor = if mouse_wheel_y > 0.0 { 0.9 } else { 1.1 };
            ortho_scale *= zoom_factor;
            ortho_scale = ortho_scale.clamp(0.5, 10000.0);
        }

        let mouse_position: Vec2 = mouse_position().into();
        let mouse_delta = mouse_position - last_mouse_position;
        last_mouse_position = mouse_position;

        if grabbed || is_mouse_button_down(MouseButton::Right) {
            yaw += mouse_delta.x * delta * LOOK_SPEED;
            pitch += mouse_delta.y * delta * -LOOK_SPEED;
            pitch = pitch.clamp(-1.5, 1.5);

            front = vec3(
                yaw.cos() * pitch.cos(),
                pitch.sin(),
                yaw.sin() * pitch.cos(),
            )
            .normalize();

            right = front.cross(world_up).normalize();
            up = right.cross(front).normalize();
        }

        clear_background(BLACK);

        let camera = Camera3D {
            position,
            up,
            target: position + front,
            projection: Projection::Orthographics,
            fovy: ortho_scale * 2.0,
            z_far: 100000.0,
            z_near: -100000.0,
            ..Default::default()
        };
        set_camera(&camera);

        draw_cube_wires(vec3(90., 9., 17.), vec3(2., 2., 2.), GREEN);
        draw_cube_wires(vec3(0., 1., 6.), vec3(2., 2., 2.), BLUE);
        draw_cube_wires(vec3(16., 13., 8.), vec3(2., 2., 2.), RED);

        let drawn = {
            let mut gl = unsafe { get_internal_gl() };
            gl.flush();

            gl.quad_context.begin_default_pass(miniquad::PassAction::Nothing);
            let view_proj: glam::Mat4 = camera.matrix();
            let drawn = stage.draw_visible(&mut *gl.quad_context, &view_proj);
            gl.quad_context.end_render_pass();
            drawn
        };

        // GUI buttons for color mode
        set_default_camera();

        let button_y = 35.0;
        let button_h = 25.0;
        let button_w = 130.0;
        let button_spacing = 5.0;
        let mut new_color_mode = color_mode;

        for (i, mode) in COLOR_MODES.iter().enumerate() {
            let bx = 10.0 + i as f32 * (button_w + button_spacing);
            let is_active = *mode == color_mode;

            let bg_color = if is_active {
                Color::new(0.3, 0.6, 1.0, 0.9)
            } else {
                Color::new(0.2, 0.2, 0.2, 0.8)
            };
            draw_rectangle(bx, button_y, button_w, button_h, bg_color);
            draw_rectangle_lines(bx, button_y, button_w, button_h, 1.0, WHITE);

            let text_x = bx + 8.0;
            let text_y = button_y + 17.0;
            draw_text(mode.label(), text_x, text_y, 18.0, WHITE);

            // Check click
            if is_mouse_button_pressed(MouseButton::Left) {
                let (mx, my) = macroquad::input::mouse_position();
                if mx >= bx && mx <= bx + button_w && my >= button_y && my <= button_y + button_h {
                    new_color_mode = *mode;
                }
            }
        }

        if new_color_mode != color_mode {
            color_mode = new_color_mode;
            let gl = unsafe { get_internal_gl() };
            stage.rebuild_colors(&mut *gl.quad_context, color_mode);
        }

        draw_text(
            &format!(
                "{}/{} points | {} chunks | FPS: {:.0} | zoom: {:.1}",
                drawn, stage.total_points, stage.chunks.len(),
                1.0 / get_frame_time(), ortho_scale
            ),
            10.0,
            20.0,
            20.0,
            WHITE,
        );

        next_frame().await
    }
}

Dit is vrij eenvoudig.

  • Regels 11-35 stellen de verschillende methoden voor het inkleuren van punten in.
  • Regels 56 tot 76 stellen de weergavevectoren in.
  • De loop begint op regel 78. Deze wordt voor elk frame uitgevoerd. Sommige game-engines abstraheren dit weg, maar macroquad gebruikt een expliciete loop, en alles wat dynamisch is moet daarbinnen gebeuren.
  • Regels 79 tot 128 verzorgen de navigatie met muis en toetsenbord. De weergave kan worden verschoven met WASD voor ons gamers en met de pijltjestoetsen voor de rest. Je kunt rondkijken door de rechtermuisknop ingedrukt te houden of door de muis te vangen met Tab, en inzoomen met het muiswiel.
  • Regel 130 stelt de achtergrondkleur in. Je zou twee knoppen kunnen toevoegen om tussen zwart en wit te wisselen.
  • Regels 132 tot 142 definiëren de 3D-camera. Het belangrijkste is dat die in orthografische modus staat. Perspectiefmodus is ook mogelijk.
  • Regels 144 tot 146 tekenen drie draadmodelkubussen. Dit is een voorbeeld dat laat zien hoe je vectorgeometrie in de 3D-cameraruimte tekent.
  • Regels 148 tot 157 tekenen vervolgens de point cloud.
  • Regel 160 zet de camera terug in 2D-modus om GUI-elementen te tekenen.
  • Regels 168 tot 197 tekenen de knoppen en voegen de knoplogica toe. Clode heeft ervoor gekozen om elke knop als rechthoek te tekenen, maar macroquad heeft zijn eigen GUI-functionaliteit. Je kunt ook de populaire egui-crate met macroquad gebruiken.
  • Tot slot tekenen regels 199 tot 209 de infotekst bovenaan het scherm.

Hier is het miniquad-deel:

mod raw_miniquad {
    use std::collections::HashMap;

    use las::{Point, Reader};
    use miniquad::*;

    use crate::ColorMode;

    const CHUNK_SIZE: f32 = 50.0;

    /// Point cloud backed by las::Point for full attribute access.
    pub struct VizPointcloud {
        pub points: Vec<Point>,
        pub min: [f64; 3],
        pub max: [f64; 3],
    }

    impl VizPointcloud {
        pub fn load(filename: &str) -> VizPointcloud {
            let mut reader = Reader::from_path(filename).unwrap();
            let n = reader.header().number_of_points() as usize;

            let mut points = Vec::with_capacity(n);
            reader.read_all_points_into(&mut points).unwrap();

            let mut min = [f64::MAX; 3];
            let mut max = [f64::MIN; 3];
            for point in &points {
                min[0] = min[0].min(point.x);
                min[1] = min[1].min(point.y);
                min[2] = min[2].min(point.z);
                max[0] = max[0].max(point.x);
                max[1] = max[1].max(point.y);
                max[2] = max[2].max(point.z);
            }

            println!("{} points loaded from {}", points.len(), filename);
            VizPointcloud { points, min, max }
        }
    }

    #[repr(C)]
    #[derive(Clone, Copy)]
    pub struct Vertex {
        pub pos: [f32; 3],
        pub color: [f32; 3],
    }

    pub struct Chunk {
        bindings: Bindings,
        num_points: i32,
        aabb_min: [f32; 3],
        aabb_max: [f32; 3],
        point_indices: Vec<usize>, // indices into VizPointcloud::points
    }

    pub struct Stage {
        pipeline: Pipeline,
        pub chunks: Vec<Chunk>,
        pub total_points: i32,
        pub cloud: VizPointcloud,
        positions: Vec<[f32; 3]>, // cached positions relative to min
    }

    fn color_for_point(point: &Point, pos_y: f32, height_range: f32, mode: ColorMode) -> [f32; 3] {
        match mode {
            ColorMode::Height => {
                let h = if height_range > 0.0 { (pos_y / height_range).clamp(0.0, 1.0) } else { 0.5 };
                let r = (2.0 * h - 0.5).clamp(0.0, 1.0);
                let g = (1.0 - 2.0 * (h - 0.5).abs()).clamp(0.0, 1.0);
                let b = (1.0 - 2.0 * h).clamp(0.0, 1.0);
                [r, g, b]
            }
            ColorMode::Intensity => {
                // Normalize intensity (u16 0..65535) to 0..1
                let i = (point.intensity as f32 / 65535.0).clamp(0.0, 1.0);
                [i, i, i]
            }
            ColorMode::Classification => {
                classification_color(point.classification.into())
            }
            ColorMode::Rgb => {
                if let Some(color) = &point.color {
                    // las colors are u16 (0..65535)
                    let r = color.red as f32 / 65535.0;
                    let g = color.green as f32 / 65535.0;
                    let b = color.blue as f32 / 65535.0;
                    [r, g, b]
                } else {
                    [0.5, 0.5, 0.5]
                }
            }
        }
    }

    fn classification_color(class: u8) -> [f32; 3] {
        match class {
            0 => [0.5, 0.5, 0.5],     // Created/Never classified - grey
            1 => [0.6, 0.6, 0.6],     // Unclassified - light grey
            2 => [0.6, 0.4, 0.2],     // Ground - brown
            3 => [0.0, 0.8, 0.0],     // Low Vegetation - green
            4 => [0.0, 0.6, 0.0],     // Medium Vegetation - darker green
            5 => [0.0, 0.4, 0.0],     // High Vegetation - dark green
            6 => [1.0, 0.0, 0.0],     // Building - red
            7 => [1.0, 0.5, 0.0],     // Low Point (noise) - orange
            8 => [0.8, 0.8, 0.0],     // Reserved / Model Key-point
            9 => [0.0, 0.0, 1.0],     // Water - blue
            10 => [0.8, 0.8, 0.8],    // Rail
            11 => [0.3, 0.3, 0.3],    // Road Surface
            12 => [0.9, 0.9, 0.0],    // Reserved / Overlap
            13 => [0.7, 0.7, 0.0],    // Wire - Guard
            14 => [0.9, 0.9, 0.0],    // Wire - Conductor
            15 => [0.4, 0.0, 0.6],    // Transmission Tower - purple
            16 => [0.6, 0.0, 0.8],    // Wire - Connector
            17 => [0.2, 0.2, 0.6],    // Bridge Deck
            18 => [1.0, 0.0, 0.5],    // High Noise
            _ => {
                // Pseudo-random color for other classes
                let h = (class as f32 * 0.618033988) % 1.0;
                hsv_to_rgb(h, 0.8, 0.9)
            }
        }
    }

    fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] {
        let i = (h * 6.0).floor();
        let f = h * 6.0 - i;
        let p = v * (1.0 - s);
        let q = v * (1.0 - f * s);
        let t = v * (1.0 - (1.0 - f) * s);
        match (i as i32) % 6 {
            0 => [v, t, p],
            1 => [q, v, p],
            2 => [p, v, t],
            3 => [p, q, v],
            4 => [t, p, v],
            _ => [v, p, q],
        }
    }

    fn extract_frustum_planes(vp: &glam::Mat4) -> [[f32; 4]; 6] {
        let c = vp.to_cols_array_2d();
        let mut planes = [[0.0f32; 4]; 6];
        for i in 0..4 {
            planes[0][i] = c[i][3] + c[i][0];
            planes[1][i] = c[i][3] - c[i][0];
            planes[2][i] = c[i][3] + c[i][1];
            planes[3][i] = c[i][3] - c[i][1];
            planes[4][i] = c[i][3] + c[i][2];
            planes[5][i] = c[i][3] - c[i][2];
        }
        for plane in &mut planes {
            let len = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
            if len > 0.0 {
                for v in plane.iter_mut() {
                    *v /= len;
                }
            }
        }
        planes
    }

    fn aabb_outside_frustum(planes: &[[f32; 4]; 6], amin: &[f32; 3], amax: &[f32; 3]) -> bool {
        for plane in planes {
            let px = if plane[0] >= 0.0 { amax[0] } else { amin[0] };
            let py = if plane[1] >= 0.0 { amax[1] } else { amin[1] };
            let pz = if plane[2] >= 0.0 { amax[2] } else { amin[2] };
            if plane[0] * px + plane[1] * py + plane[2] * pz + plane[3] < 0.0 {
                return true;
            }
        }
        false
    }

    impl Stage {
        pub fn new(ctx: &mut dyn RenderingBackend, filename: &str, color_mode: ColorMode) -> Stage {
            let cloud = VizPointcloud::load(filename);

            let height_range = (cloud.max[2] - cloud.min[2]) as f32;

            let positions: Vec<[f32; 3]> = cloud
                .points
                .iter()
                .map(|p| [
                    (p.x - cloud.min[0]) as f32,
                    (p.z - cloud.min[2]) as f32, // z -> y (up)
                    (p.y - cloud.min[1]) as f32,
                ])
                .collect();

            let vertices: Vec<Vertex> = cloud
                .points
                .iter()
                .enumerate()
                .map(|(i, p)| Vertex {
                    pos: positions[i],
                    color: color_for_point(p, positions[i][1], height_range, color_mode),
                })
                .collect();

            let mut chunk_map: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
            for (i, pos) in positions.iter().enumerate() {
                let cx = (pos[0] / CHUNK_SIZE).floor() as i32;
                let cz = (pos[2] / CHUNK_SIZE).floor() as i32;
                chunk_map.entry((cx, cz)).or_default().push(i);
            }

            let shader = ctx
                .new_shader(
                    miniquad::ShaderSource::Glsl {
                        vertex: shader::VERTEX,
                        fragment: shader::FRAGMENT,
                    },
                    shader::meta(),
                )
                .unwrap();

            let pipeline = ctx.new_pipeline(
                &[BufferLayout::default()],
                &[
                    VertexAttribute::new("pos", VertexFormat::Float3),
                    VertexAttribute::new("color", VertexFormat::Float3),
                ],
                shader,
                PipelineParams {
                    primitive_type: PrimitiveType::Points,
                    depth_test: Comparison::LessOrEqual,
                    depth_write: true,
                    ..Default::default()
                },
            );

            let total_points = vertices.len() as i32;

            let chunks: Vec<Chunk> = chunk_map
                .into_iter()
                .map(|(_key, point_indices)| {
                    let chunk_verts: Vec<Vertex> =
                        point_indices.iter().map(|&i| vertices[i]).collect();

                    let mut aabb_min = [f32::MAX; 3];
                    let mut aabb_max = [f32::MIN; 3];
                    for v in &chunk_verts {
                        for j in 0..3 {
                            aabb_min[j] = aabb_min[j].min(v.pos[j]);
                            aabb_max[j] = aabb_max[j].max(v.pos[j]);
                        }
                    }

                    let n = chunk_verts.len() as i32;
                    let vertex_buffer = ctx.new_buffer(
                        BufferType::VertexBuffer,
                        BufferUsage::Dynamic,
                        BufferSource::slice(&chunk_verts),
                    );
                    let indices: Vec<u32> = (0..n as u32).collect();
                    let index_buffer = ctx.new_buffer(
                        BufferType::IndexBuffer,
                        BufferUsage::Immutable,
                        BufferSource::slice(&indices),
                    );

                    Chunk {
                        bindings: Bindings {
                            vertex_buffers: vec![vertex_buffer],
                            index_buffer,
                            images: vec![],
                        },
                        num_points: n,
                        aabb_min,
                        aabb_max,
                        point_indices,
                    }
                })
                .collect();

            println!("{} points in {} chunks ({}m cells), height range: {:.1}m",
                total_points, chunks.len(), CHUNK_SIZE, height_range);

            Stage {
                pipeline,
                chunks,
                total_points,
                cloud,
                positions,
            }
        }

        pub fn rebuild_colors(&mut self, ctx: &mut dyn RenderingBackend, color_mode: ColorMode) {
            let height_range = (self.cloud.max[2] - self.cloud.min[2]) as f32;

            for chunk in &mut self.chunks {
                let chunk_verts: Vec<Vertex> = chunk
                    .point_indices
                    .iter()
                    .map(|&i| {
                        let p = &self.cloud.points[i];
                        Vertex {
                            pos: self.positions[i],
                            color: color_for_point(p, self.positions[i][1], height_range, color_mode),
                        }
                    })
                    .collect();

                ctx.buffer_update(
                    chunk.bindings.vertex_buffers[0],
                    BufferSource::slice(&chunk_verts),
                );
            }
        }

        pub fn draw_visible(&self, ctx: &mut dyn RenderingBackend, view_proj: &glam::Mat4) -> i32 {
            let planes = extract_frustum_planes(view_proj);
            let mut drawn = 0;

            ctx.apply_pipeline(&self.pipeline);
            for chunk in &self.chunks {
                if aabb_outside_frustum(&planes, &chunk.aabb_min, &chunk.aabb_max) {
                    continue;
                }
                ctx.apply_bindings(&chunk.bindings);
                ctx.apply_uniforms(miniquad::UniformsSource::table(
                    &shader::Uniforms {
                        mvp: *view_proj,
                    },
                ));
                ctx.draw(0, chunk.num_points, 1);
                drawn += chunk.num_points;
            }
            drawn
        }
    }

    pub mod shader {
        use miniquad::*;

        pub const VERTEX: &str = r#"#version 100
            attribute vec3 pos;
            attribute vec3 color;

            uniform mat4 mvp;

            varying lowp vec3 v_color;

            void main() {
                gl_Position = mvp * vec4(pos, 1.0);
                gl_PointSize = 2.0;
                v_color = color;
            }"#;

        pub const FRAGMENT: &str = r#"#version 100
            varying lowp vec3 v_color;

            void main() {
                gl_FragColor = vec4(v_color, 1.0);
            }"#;

        pub fn meta() -> ShaderMeta {
            ShaderMeta {
                images: vec![],
                uniforms: UniformBlockLayout {
                    uniforms: vec![
                        UniformDesc::new("mvp", UniformType::Mat4),
                    ],
                },
            }
        }

        #[repr(C)]
        pub struct Uniforms {
            pub mvp: glam::Mat4,
        }
    }
}

Ook dit is weer goed te volgen:

  • Regels 12 tot 40 definiëren een point cloud-struct en de functie voor het laden van een point cloud, waarvoor de las-crate wordt gebruikt.
  • Regels 42 tot 139 definiëren de benodigde structs en de functies voor het inkleuren van de punten. Bij het inkleuren op intensiteit wordt uitgegaan van intensiteiten van 0 tot 65535 – een verbetering zou zijn om het werkelijke intensiteitsbereik te gebruiken, zoals bij het hoogtebereik gebeurt.
  • Regels 141 tot 173 verzorgen de frustum culling om het aantal te tekenen punten te beperken.
  • Regels 175 tot 332 definiëren de Stage, waar de daadwerkelijke rendering plaatsvindt. Dit omvat het berekenen van lokale coördinaten door het minimum van de cloud af te trekken, het toevoegen van de kleuren, het definiëren van de renderpipeline en het opdelen van de punten in chunks.
  • Ten slotte wordt vanaf regel 334 de shader gedefinieerd. Eén enkele shader is voldoende omdat elk punt zijn eigen kleurwaarde meedraagt. Zaken als hoogtegradiënten zouden ook in een shader kunnen zitten, en dat zou zinvol zijn voor dynamische shaders die in elk frame van uiterlijk veranderen, maar hier is dat niet nodig.

Als je de frustum culling niet nodig hebt, kun je de code flink inkorten.

En tot slot het cargo.toml-bestand:

[package]
name = "viewer"
version = "0.1.0"
edition = "2024"

[dependencies]
macroquad = "0.4"
las = { version = "0.9.10", features = ["laz-parallel"] }
console = "0.16"
miniquad = "0.4"
glam = { version="0.27" }

Het zijn echt weinig dependencies. console zou je gemakkelijk kunnen weglaten; het zit er alleen in omdat ik het in andere programma’s gebruik voor het inkleuren van uitvoer, meestal in combinatie met indicatif voor mooie voortgangsbalken.

Conclusies

Ik ben erg blij met het resultaat. Macroquad is heel kaal, maar het heeft precies wat ik nodig heb: basisfuncties voor het tekenen van 3D-objecten, wat GUI-functionaliteit en een eenvoudig programmeermodel. Ik ben ook behoorlijk verrast hoe goed Claude Code mijn wensen wist te implementeren nadat ik vastliep op de voorbeelden. Het nadeel is dat het lastiger wordt om te begrijpen wat er gebeurt en waarom dingen niet werken. In eerste instantie kreeg ik alleen een zwart scherm, en Claude had drie pogingen nodig om dat op te lossen.

Een reactie plaatsen

Je e-mailadres wordt niet gepubliceerd. Vereiste velden zijn gemarkeerd met *

Een gedachte over “Zo schrijf je je eigen puntenwolk viewer met Rust en macroquad”