TECH3

CLICK, PAINT ... GO!​​

04                                        2025-2026

In dit onderdeel duiken we dieper in de interactieve kant van onze 3D-wereld. We leren hoe we mouse events kunnen gebruiken om objecten te manipuleren, hoe we materialen en texturen realistisch toepassen.

CLICK, PAINT ... GO!

MOUSE EVENTS

01

01

MOUSE EVENTS

ONCLICK

Een klikactie wordt uitgevoerd door een functie te koppelen aan het onClick-attribuut van de mesh.

export default function Experience()
{
    // ...
    const eventHandler = () =>
    {
        console.log('the event occured')
    }
    // ...
}

In je Experience definieer je een functie voor het onClick-event.

Koppel deze functie aan het onClick-attribuut van de mesh.

<mesh ref={ cube } position-x={ 2 } scale={ 1.5 } onClick={ eventHandler } >
     //...
</mesh>

01

MOUSE EVENTS

ONCLICK

Om een visueel effect bij klikken te tonen, veranderen we de kleur van het materiaal. Dankzij onze referentie naar de mesh kan dit eenvoudig.

const eventHandler = () => {
    if (!cubeRef.current) return;
    const mat = cubeRef.current.material as THREE.MeshStandardMaterial;
    mat.color.set(`hsl(${Math.random() * 360}, 100%, 75%)`)
}

Pas het kleur aan via de reference van de cube.

Dit werkt ook voor mobiele apparaten door te tappen op je object.

01

MOUSE EVENTS

EVENT INFO

Door het event-argument in je functie op te nemen, kun je alle informatie over de klik opvragen.

const eventHandler = (event) =>
{
    console.log(event)
    //...
}

Voeg een event parameter toe aan je functie eventHandler

De console toont nuttige gegevens, zoals de afstand, de x- en y-coördinaten, en of toetsen zoals Shift of Ctrl zijn ingedrukt...

01

MOUSE EVENTS

EVENT INFO

Overzicht van de belangrijkste elementen:

  • Distance: Afstand van camera tot het raakpunt op het object.

  • Point: 3D-coördinaten van het raakpunt op het object.

  • UV: UV-coördinaten op het oppervlak van het object bij het raakpunt.

  • Object: Het 3D-object dat geraakt is (bv. mesh).

  • eventObject: Het object dat het event registreerde.
     

  • event.x: X-positie van de muis.

  • event.y: Y-positie van de muis.
     

  • shiftKey: True als Shift ingedrukt is.

  • ctrlKey: True als Ctrl ingedrukt is.

  • metaKey: True als Command (Mac) ingedrukt is.

01

MOUSE EVENTS

OTHER EVENTS

Naast onClick zijn er nog veel andere events beschikbaar.

  • onClick: Klik met links of tap op het object.

  • onContextMenu: Klik met rechts (open contextmenu).

  • onDoubleClick: Dubbele klik of tap.

  • onPointerDown: Klik of vinger neerzetten op object.

  • onPointerUp: Klik of tap loslaten.

  • onPointerOver: Cursor of vinger boven object.

  • onPointerEnter: Zelfde als onPointerOver.

  • onPointerOut: Cursor of vinger verlaat object.

  • onPointerLeave: Zelfde als onPointerOut.

  • onPointerMove: Beweging van cursor/vinger over object.

  • onPointerMissed: Klik buiten het object.

01

MOUSE EVENTS

MISSED

Het onPointerMissed-event registreert klikken die niet op een object plaatsvinden. Dit attribuut wordt op de Canvas toegepast, zodat het kan luisteren naar alle interacties met objecten.

Voeg de onPointerMissed attribuut to aan je Canvas

<Canvas
    camera={ {
        fov: 45,
        near: 0.1,
        far: 50,
        position: [ - 4, 3, 6 ]
    } }
    onPointerMissed={ () => { console.log('You missed!') } }
>
    <Experience />
</Canvas>

Heeft een bepaald object geen klikevent dan zal deze ook bij de missed toebehoren.

01

MOUSE EVENTS

OCCLUDING

Wanneer we de scene draaien en de sphere voor de cube plaatsen, blijft een klik doorwerken. Dit ongewenste gedrag kan worden voorkomen met de stopPropagation-methode.

Voeg een onClick toe aan de sphere en call de stopPropagation method

<mesh position-x={ - 2 } onClick={ (event) => event.stopPropagation() }>
    {/* ... */}
</mesh>

01

MOUSE EVENTS

CURSOR

Wanneer een object interactief is, is het belangrijk om dit visueel aan te geven. Hiervoor kunnen de events onPointerEnter en onPointerLeave worden gebruikt.

Voeg deze events toe aan je cube.

<mesh
    ref={ cube }
    position-x={ 2 }
    scale={ 1.5 }
    onClick={ eventHandler }
    onPointerEnter={ () => { document.body.style.cursor = 'pointer' } }
    onPointerLeave={ () => { document.body.style.cursor = 'default' } }
>

Vergeet niet om je andere objecten buiten de calculation te halen.

<mesh
    position-x={ - 2 }
    onClick={ (event) => event.stopPropagation() }
    onPointerEnter={ (event) => event.stopPropagation() }
>

01

MOUSE EVENTS

COMPLEX

Bij een object dat uit verschillende onderdelen bestaat, kan een klik-event onverwachte resultaten geven. We gaan dit testen en onderzoeken hoe we dit kunnen aanpakken.

Voeg een complex model toe via de useGLTF hook van Drei.

export default function Experience()
{
    // ...
    const christmasGlobe = useGLTF('./model/christmas_ball.glb')
    
     return <>
        {/* ... */}
        <primitive
            object={ christmasGlobe.scene }
            scale={ 0.025 }
            position-y={ -1 }
        />
    </>
}

01

MOUSE EVENTS

COMPLEX

Voeg een onClick-event toe aan de primitive en test het door op het object te klikken.

<primitive
    object={ christmasGlobe.scene }
    scale={ 0.25 }
    position-y={ 0.5 }
    onClick={ (event) =>
    {
        console.log('click')
    } }
/>

Geef het event-argument mee in je onClick-functie.

<primitive
    object={ christmasGlobe.scene }
    scale={ 0.25 }
    position-y={ 0.5 }
    onClick={ (event) =>
    {
        console.log(event.object)
    } }
/>

01

MOUSE EVENTS

STOP

Door stopPropagation toe te passen, zal slechts het eerst aangeraakte object worden geregistreerd. De ray passeert alle objecten, maar alleen het eerste wordt in de console weergegeven.

<primitive
    object={ christmasGlobe.scene }
    scale={ 0.25 }
    position-y={ 0.5 }
    onClick={ (event) =>
    {
        console.log(event.object)
        event.stopPropagation()
    } }
/>

01

MOUSE EVENTS

EVENTS 

De uitgevoerde events zijn CPU-intensief, dus optimalisatie is belangrijk. Gelukkig biedt Drei een helper om dit te vergemakkelijken.

import { meshBounds } from '@react-three/drei'

<mesh
    ref={ cube }
    raycast={ meshBounds }
    position-x={ 2 }
    scale={ 1.5 }
    onClick={ eventHandler }
    onPointerEnter={ () => { document.body.style.cursor = 'pointer' } }
    onPointerLeave={ () => { document.body.style.cursor = 'default' } }
>
    {/* ... */}
</mesh>

Importeer meshBounds en geef het met het 'raycast' attribuut mee in de cube. 

Let op: dit werkt alleen voor objecten die uit één geheel bestaan; bij complexe objecten functioneert het niet.

01

MOUSE EVENTS

BVH

Bij complexe geometrieën biedt Drei de Bvh helper (Bounding Volume Hierarchy). Deze helper genereert voor elke mesh bounds om interacties te versnellen, MAAR kan een korte freeze veroorzaken bij het starten van de experience.

import { Bvh } from '@react-three/drei'
    
<Canvas
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ - 4, 3, 6 ]
    } }
    onPointerMissed={ () => { console.log('You missed!') } }
>
    <Bvh>
        <Experience />
    </Bvh>
</Canvas>

Importeer Bvh van drei en wrap onze experience hier rond. 

01

MOUSE EVENTS

BEWARE!

Bepaalde mouse-events zijn CPU-intensief omdat ze op elk frame worden gecontroleerd. Gebruik deze daarom spaarzaam om performanceproblemen te voorkomen.

De mouse events in kwestie zijn:

  • onPointerOver
  • onPointerEnter
  • onPointerOut
  • onPointerLeave
  • onPointerMove

CLICK, PAINT ... GO!

TEXTURES

02

02

TEXTURES

WHAT?

Een texture is een afbeelding die op een 3D-oppervlak wordt toegepast om details aan een object toe te voegen. Zie het als verf of een foto die om een geometrie wordt gewrapt. Bij textures gebruiken we verschillende PBR-maps.

  • Color / Diffuse Map: Basisafbeelding die de kleur van het materiaal bepaalt.

  • Normal Map: Simuleert kleine oneffenheden voor realistisch licht- en schaduweffect.

  • Roughness Map: Regelt de mate van glans of matte uitstraling van het oppervlak.

  • Displacement Map: Past geometrie aan voor echte hoogteverschillen; extra polygonen nodig.

  • Ambient Occlusion Map: Voegt diepe schaduwen toe in hoeken en kieren.

  • ...

02

TEXTURES

USETEXTURE

Met de useTexture-hook van Drei kunnen we textures eenvoudig importeren en direct op een mesh toepassen, zonder extra configuratie.
Drei is een godsgeschenk...

Zoek een online texture, importeer deze via de useTexture-hook van Drei en wijs hem toe aan het materiaal van je mesh.

import { useTexture } from '@react-three/drei'

  const { colorMap, normalMap, aoMap } = useTexture({
    colorMap: './textures/brick/brick_color.png',
    normalMap: './textures/brick/brick_normal.png',
    aoMap: './textures/brick/brick_ao.png',
  })
  return (
    <>
      {/* Sphere with multiple maps */}
      <mesh>
        <sphereGeometry/>
        <meshStandardMaterial
          color={color || "white"}
          map={colorMap}      // base color
          normalMap={normalMap} // bumps
          aoMap={aoMap}       // ambient occlusion
          aoMapIntensity={1}  // adjust strength
        />
      </mesh>
    </>
  )

02

TEXTURES

WRAP - REPEAT

Soms kan een texture uitrekken of verkeerd uitgelijnd zijn. Dit kun je controleren en corrigeren met wrapS en wrapT, waarvoor de originele THREE.js-library nodig is.

Pas je texture aan met WrapS en WrapT zodat de stretch weg is. 

const texture = useTexture('./textures/brick/brick_color.png')
texture.wrapS = texture.wrapT = THREE.RepeatWrapping
texture.repeat.set(3, 2)

02

TEXTURES

FILTERS

Filtering bepaalt hoe een texture wordt gesampled bij het vergroten of verkleinen. Zonder filtering kan de texture wazig of pixelachtig lijken.

Type Wanneer? Opties
Minification Filter (minFilter) Wanneer de texture verkleind is (Ver weg of klein op het scherm) LinearFilter (default, smooth) / NearestFilter (pixelated)
Magnification Filter (magFilter) Wanneer de texture vergroot is (te dicht bij camera) LinearFilter (default, smooth) / NearestFilter (pixelated)

Meest gebruikte filters:

  • THREE.LinearFilter: Smooth interpolatie (standaard).

  • THREE.NearestFilter: Scherpe, blokkerige weergave.

  • THREE.LinearMipMapLinearFilter: Smooth interpolatie met mipmaps.

02

TEXTURES

MULTIPLE

De hook ondersteunt meerdere textures tegelijk. Deze worden als array geretourneerd, waardoor je ze eenvoudig kunt destructuren en gebruiken.

Voeg volgende textures toe aan je hook en geef deze mee aan de material.

const [colorMap, normalMap, aoMap ] = useTexture([
    './textures/brick/brick_color.png',
    './textures/brick/brick_normal.png',
    './textures/brick/brick_ao.png',
])

<meshStandardMaterial
  map={colorMap}
  normalMap={normalMap}
  aoMap={aoMap}
/>

02

TEXTURES

MAPS

Voor een StandardMaterial zijn de volgende texture maps beschikbaar:

map function
map base color
normalMap bumps
aoMap ambient shadow
roughnessMap  rough/smooth areas
metalnessMap metallic parts
displacementMap geometry bumps (effectief)

02

TEXTURES

DYNAMIC

Het is mogelijk om textures dynamisch te wijzigen, wat handig is voor bijvoorbeeld een configurator. Hiervoor gebruiken we een useState om het huidige materiaal op te slaan en te updaten. (Later via store)

Maak een switch bij de onClick zodat hij van material verandert.

import { useState } from 'react'
import { SRGBColorSpace } from "three"

const brick = useTexture('./textures/brick/brick_color.png')
const wood = useTexture('./textures/wood/wood_color.png')
const [current, setCurrent] = useState(brick)

brick.colorSpace = SRGBColorSpace;
wood.colorSpace = SRGBColorSpace;

return (
  <>
    <mesh onClick={() => setCurrent(current === brick ? wood : brick)}>
      <boxGeometry />
      <meshStandardMaterial color={color || "white"} map={current} />
    </mesh>
    <OrbitControls />
  </>
)

02

TEXTURES

IMPORTANT

Bij het werken met textures zijn er een aantal cruciale aandachtspunten waar je rekening mee moet houden.

  1. De afbeelding moet in machten van 2 zijn (bijv. 512×512, 1024×1024) voor optimale performance.
  2. Wanneer objecten verwijderd worden, moet de texture worden vrijgegeven met de .dispose()-methode.
  3. Zorg dat de belichting correct is, aangezien het StandardMaterial hier invloed op heeft.
if (meshRef.current) {
  const mesh = meshRef.current;

  // Dispose geometry
  mesh.geometry.dispose();

  // Dispose all materials (in case it's an array)
  if (Array.isArray(mesh.material)) {
    mesh.material.forEach((m) => m.dispose());
  } else {
    mesh.material.dispose();
  }
}

//of als je object een R3F component is kan je volgende uitvoeren 

{isVisible && <mesh ... />}

useEffect(() => {
  return () => {
    meshRef.current?.geometry.dispose();
    (meshRef.current?.material as any)?.dispose();
  };
}, []);

02

TEXTURES

.GLB

GLB-bestanden exporteert Blender vaak met alle textures ingebed. Deze bestanden kunnen eenvoudig worden geladen met de useGLTF-hook, zonder aparte texture-import.

Variant 1: Als je image-based textures zoals PNG’s of JPG’s in Blender gebruikt en deze koppelt aan base color, normal, enz., worden deze ingesloten in het GLB-bestand.

Variant 2: Procedural of node-based materialen (zoals Noise, Voronoi, etc.) kunnen niet rechtstreeks naar GLB worden geëxporteerd. Meestal verschijnen deze als grijs, omdat de data niet wordt meegenomen. Om dit op te lossen, moeten de textures worden gebakken (baken). -> Zie online tutorials

CLICK, PAINT ... GO!

EXPORT BLENDER

03

03

EXPORT BLENDER

HOW

Als je een model in Blender hebt ontworpen, kun je dit exporteren naar GLB voor gebruik in 3D-applicaties. We laten nu zien hoe dit werkt.

Selecteer je model in kwestie en ga naar File -> Export -> GLTF 2.0 (.glb/.gltf)

03

EXPORT BLENDER

SETTINGS

In het volgende scherm zie je de exportopties. Aan de rechterzijde kun je verschillende instellingen aanpassen; vaak zijn de standaardwaarden voldoende.

03

EXPORT BLENDER

NAME IT

Geef het bestand een naam en sla het op op de gewenste locatie. Het object is nu geëxporteerd. Test het vervolgens in een scene of upload het naar gltf.pmnd.rs voor preview.

CLICK, PAINT ... GO!

ZUSTAND

04

04

ZUSTAND

FRESH

We maken een nieuw project aan en hervatten ons werk aan de clicker-applicatie ... OH NO

We creëren eerst onze Counter-component en geven deze een stijl, zonder functionaliteit. Plaats de volgende code in Counter.tsx.

import "./counter.css";

const Counter = () => {

  return (
    <div className="counter-card">
      <h1 className="counter-title">🧮 Counter ... again</h1>

      <p className="counter-value">count placeholder</p>

      <div className="button-group">
        <button className="btn minus">
          –
        </button>
        <button className="btn reset">
          Reset
        </button>
        <button className="btn plus">
          +
        </button>
      </div>
    </div>
  );
}

export default Counter;

04

ZUSTAND

FRESH

Voeg de styling toe in counter.css en pas deze eventueel ook toe in index.css.

counter.css

/* Neumorfische, moderne stijl met zachte schaduwen en ronde vormen */

.counter-card {
  background: #f2f5f9;
  box-shadow: 8px 8px 16px #d0d4da, -8px -8px 16px #ffffff;
  border-radius: 24px;
  padding: 40px;
  text-align: center;
  width: 300px;
  transition: all 0.3s ease;
}


.counter-title {
  font-size: 1.6rem;
  font-weight: bold;
  color: #333;
  margin-bottom: 16px;
}

.counter-value {
  font-size: 3.5rem;
  font-weight: bold;
  color: #2d6cdf;
  margin: 20px 0;
  text-shadow: 2px 2px 6px rgba(0, 0, 0, 0.1);
}

.button-group {
  display: flex;
  justify-content: space-around;
  align-items: center;
  gap: 12px;
}

.btn {
  border: none;
  outline: none;
  border-radius: 16px;
  padding: 12px 18px;
  font-size: 1rem;
  cursor: pointer;
  color: white;
  font-weight: bold;
  transition: all 0.25s ease;
  box-shadow: 3px 3px 6px rgba(0, 0, 0, 0.15);
}

.btn.plus {
  background: linear-gradient(135deg, #3b82f6, #2563eb);
}

.btn.minus {
  background: linear-gradient(135deg, #ef4444, #dc2626);
}

.btn.reset {
  background: linear-gradient(135deg, #6b7280, #4b5563);
}

.btn:hover {
  transform: scale(1.07);
  box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.2);
}

index.css

.app-container {
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: #e9ecef;
  height: 100vh;
}

04

ZUSTAND

STORE

Om de counter-variabele en bijbehorende functies te delen tussen de Counter-component en andere onderdelen van het project, gebruiken we Zustand. Deze library maakt globale state management mogelijk via een eenvoudige custom hook.

Maak een store aan genaamd counterStore in ./store/counterStore.ts en plaats er volgende vode in.

// Dit bestand bevat de Zustand store, die de gegevens (state) beheert.

import { create } from "zustand";

// Define the shape of our store
interface CounterStore {
  count: number;
  increase: () => void;
  decrease: () => void;
  reset: () => void;
}

// We maken een store met een tellerwaarde en twee functies om die waarde te wijzigen.
const useCounterStore = create<CounterStore>((set) => ({
  count: 0,

  increase: () => {
    set((state) => ({ count: state.count + 1 }));
  },

  decrease: () => {
    set((state) => ({ count: state.count - 1 }));
  },

  reset: () => set({ count: 0 }),
}));

export default useCounterStore;

Installeer eerst Zustand met volgende commando.

npm install zustand

04

ZUSTAND

STORE

Met de store actief kunnen we deze nu aanspreken binnen de Counter-component. Zo krijgen we toegang tot zowel de variabelen als de functies die in de store zijn gedefinieerd.

Haal de nodige zaken op uit de store en gebruik deze in de counter.

import useCounterStore from "../../store/counterStore";
import "./counter.css";

const Counter = () => {
const { count, increase, decrease , reset} = useCounterStore();

return (
  <div className="counter-card">
    <h1 className="counter-title">🧮 Counter ... again</h1>

    <p className="counter-value">{count}</p>

    <div className="button-group">
      <button className="btn minus" onClick={decrease}>
        –
      </button>
      <button className="btn reset" onClick={reset}>
        Reset
      </button>
      <button className="btn plus" onClick={increase}>
        +
      </button>
    </div>
  </div>
);
}

export default Counter;

04

ZUSTAND

STORE

Test de counter om te zien of alles correct werkt. Vergelijk dit met de manier waarop we het eerder moesten doen zonder store. Dankzij de store zijn alle functies en variabelen nu overzichtelijk samengebracht, wat het beheer en onderhoud aanzienlijk vereenvoudigt.

WEB6/4 - Click, Paint ... GO!

By Niels Minne

WEB6/4 - Click, Paint ... GO!

  • 98