
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.
- De afbeelding moet in machten van 2 zijn (bijv. 512×512, 1024×1024) voor optimale performance.
- Wanneer objecten verwijderd worden, moet de texture worden vrijgegeven met de
.dispose()-methode. - 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