So schreibt man seinen eigenen Viewer für Punktwolken mit Rust und macroquad

Ich wollte schon lange meinen eigenen Punktwolken-Viewer bauen. CloudCompare ist zwar mein täglicher Begleiter, aber ich hätte gern etwas, das ich erweitern und in dem ich eigene Algorithmen ausführen kann. Ich habe mir bereits Unity, Godot und die Unreal Engine angesehen, aber eine vollwertige Spiele-Engine ist mehr, als ich brauche, und sie sind recht kompliziert zu programmieren. Außerdem bin ich inzwischen für einen Großteil meiner Softwareentwicklung rund um Punktwolken auf Rust umgestiegen. Bevy wäre eine naheliegende Wahl, und ich möchte es mir noch genauer ansehen. Vorerst habe ich mich aber entschieden, etwas noch Leichtgewichtigeres und Hardwarenäheres auszuprobieren: macroquad. Es ist eher auf 2D ausgelegt, hat aber eine 3D-Kamera, und mehr braucht man eigentlich nicht – schließlich müssen alle Spiele-Engines 3D-Objekte auf eine flache Oberfläche projizieren.

Beim Versuch, viele Punkte als Kugeln zu zeichnen, stieß ich jedoch schnell auf Probleme. Die Performance war schlicht unbrauchbar. Die Lösung bestand darin, eine Ebene tiefer zu gehen und miniquad zu verwenden, um Vertex-Buffer direkt zu manipulieren. Dafür gibt es nur sehr wenig Dokumentation und nur ein paar Beispielprojekte, daher habe ich mir schließlich von Claude Code helfen lassen. Am Ende hatte ich einen funktionierenden Viewer mit weniger als 600 Zeilen Code.

Hier sind zwei Screenshots einer Punktwolke, dargestellt nach Höhe und nach Farbe:

Der Code

Hier ist der erste Teil, der 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
    }
}

Das ist recht unkompliziert.

  • Die Zeilen 11-35 richten die verschiedenen Methoden zur Punkteinfärbung ein.
  • Die Zeilen 56 bis 76 richten die Blickvektoren ein.
  • Die Schleife beginnt in Zeile 78. Sie wird für jedes Frame ausgeführt. Manche Spiele-Engines abstrahieren das weg, aber macroquad verwendet eine explizite Schleife, und alles Dynamische muss darin stattfinden.
  • Die Zeilen 79 bis 128 behandeln die Navigation mit Maus und Tastatur. Die Ansicht lässt sich für uns Gamer mit WASD verschieben, für alle anderen mit den Pfeiltasten. Umsehen kann man sich, indem man die rechte Maustaste gedrückt hält oder die Maus mit Tab einfängt, und mit dem Mausrad zoomt man.
  • Zeile 130 legt die Hintergrundfarbe fest. Man könnte zwei Buttons hinzufügen, um zwischen Schwarz und Weiß umzuschalten.
  • Die Zeilen 132 bis 142 definieren die 3D-Kamera. Wichtig ist vor allem, dass sie auf den orthografischen Modus eingestellt ist. Der Perspektivmodus ist ebenfalls möglich.
  • Die Zeilen 144 bis 146 zeichnen drei Drahtgitter-Würfel. Das ist ein Beispiel dafür, wie man Vektorgeometrie im 3D-Kameraraum zeichnet.
  • Die Zeilen 148 bis 157 zeichnen dann die Punktwolke.
  • Zeile 160 schaltet die Kamera zum Zeichnen der GUI-Elemente zurück in den 2D-Modus.
  • Die Zeilen 168 bis 197 zeichnen die Buttons und fügen die Button-Logik hinzu. Claude hat sich dafür entschieden, jeden Button als Rechteck zu zeichnen, aber macroquad hat eigene GUI-Funktionen. Man kann mit macroquad auch die beliebte egui-Crate verwenden.
  • Schließlich zeichnen die Zeilen 199 bis 209 den Infotext am oberen Bildschirmrand.

Hier ist der miniquad-Teil:

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,
        }
    }
}

Auch das ist wieder recht leicht verständlich:

  • Die Zeilen 12 bis 40 definieren eine Punktwolken-Struktur und die Funktion zum Laden einer Punktwolke, die die las-Crate verwendet.
  • Die Zeilen 42 bis 139 definieren die notwendigen Strukturen und die Funktionen zum Einfärben der Punkte. Die Intensitätsfärbung geht von Intensitäten von 0 bis 65535 aus – eine Verbesserung wäre, den tatsächlichen Intensitätsbereich zu verwenden, so wie es beim Höhenbereich gemacht wird.
  • Die Zeilen 141 bis 173 behandeln das Frustum Culling, um die Anzahl der zu zeichnenden Punkte zu reduzieren.
  • Die Zeilen 175 bis 332 definieren die Stage, in der das eigentliche Rendering stattfindet. Dazu gehören die Berechnung lokaler Koordinaten durch Subtraktion des Minimums der Wolke, das Hinzufügen der Farben, die Definition der Render-Pipeline und das Aufteilen der Punkte in Chunks.
  • Ab Zeile 334 schließlich wird der Shader definiert. Ein einziger Shader genügt, da jeder Punkt seinen eigenen Farbwert mitbringt. Dinge wie Höhenverläufe könnten auch im Shader liegen, und das wäre sinnvoll bei dynamischen Shadern, deren Aussehen sich in jedem Frame ändert, aber hier ist das nicht nötig.

Wenn man kein Frustum Culling braucht, ließe sich der Code noch deutlich kürzen.

Und zum Schluss die Datei cargo.toml:

[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" }

Sie kommt mit sehr wenigen Abhängigkeiten aus. console ließe sich leicht entfernen; es ist nur dabei, weil ich es in anderen Programmen für farbige Ausgaben verwende, meist in Kombination mit indicatif für schöne Fortschrittsbalken.

Fazit

Ich bin mit dem Ergebnis wirklich zufrieden. Macroquad ist sehr minimalistisch, bietet aber genau das, was ich brauche: grundlegende Funktionen zum Zeichnen von 3D-Objekten, etwas GUI-Funktionalität und ein einfaches Programmiermodell. Ich bin auch ziemlich überrascht, wie gut Claude Code meine Wünsche umgesetzt hat, nachdem ich bei den Beispielen nicht weitergekommen war. Der Nachteil ist, dass es schwieriger wird nachzuvollziehen, was vor sich geht und warum etwas nicht funktioniert. Anfangs bekam ich nur einen schwarzen Bildschirm, und Claude brauchte drei Versuche, um das zu beheben.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Ein Gedanke zu “So schreibt man seinen eigenen Viewer für Punktwolken mit Rust und macroquad”