WebGL e GIS 3D: Three.js, RenderNode e ArcGIS Maps SDK 5.1 con Vite e Node.js
Deep dive per sviluppatori. Come inserire un personaggio glTF animato nella pipeline grafica di una scena geografica, gestire un contesto WebGL condiviso, interpolare una traiettoria sul terreno e distribuire il risultato come applicazione JavaScript statica. Il caso di studio è il progetto open source externalRendererSkeleton.
Demo: Apri l’applicazione su GitHub Pages. Repository: codice sorgente. Le sezioni di codice riprendono parti del progetto e, dove esplicitamente indicato, mostrano versioni abbreviate per spiegare un meccanismo.
In sintesi. Il browser esegue SceneView, RenderNode e Three.js; Node.js serve per gli strumenti di sviluppo e la build. Vite produce il bundle statico. Il problema centrale non è caricare un GLB, ma sincronizzare camera, spazio di riferimento, framebuffer, stato WebGL e tempo fisico dell’animazione.
1. Dal vecchio externalRenderer al RenderNode moderno
Il nome del progetto rimanda al vecchio externalRenderers, ma l’implementazione corrente utilizza RenderNode con ArcGIS Maps SDK for JavaScript 5.1. L’originaria applicazione ArcGIS 4.1 e il vecchio modello Marine non appartengono più all’albero sorgente corrente: la loro storia è consultabile nei commit precedenti. Il modello della versione attuale è Soldier.glb.
Con externalRenderers la logica di rendering esterno veniva agganciata alla vista tramite un’interfaccia dedicata. Con RenderNode il contributo custom viene modellato come nodo della pipeline della scena 3D: il nodo dichiara gli input consumati, l’output prodotto e si integra nel ciclo di compositing. Questo cambia il ragionamento architetturale: non basta avere un renderer Three.js che disegna; bisogna rispettare il contratto grafico della scena che lo ospita.
Il codice sperimentale o soggetto a evoluzione del SDK richiede una verifica sulla versione effettiva in uso prima degli aggiornamenti. In particolare, i nomi degli stage del render graph e il loro contratto devono essere considerati parte dell’integrazione, non dettagli stabili e universali.
2. La toolchain: Node.js 22+, Vite 7, ES modules e dipendenze
Il repository usa moduli ECMAScript nativi ("type": "module") e un progetto Vite collocato direttamente nella radice. Le dipendenze dichiarate nel package.json sono @arcgis/core ^5.1.0, @arcgis/map-components ^5.1.0, three ^0.180.0, con vite ^7.1.0 tra le dipendenze di sviluppo. Il README richiede Node.js 22 o superiore.
{
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"test": "node --test"
},
"dependencies": {
"@arcgis/core": "^5.1.0",
"@arcgis/map-components": "^5.1.0",
"three": "^0.180.0"
},
"devDependencies": { "vite": "^7.1.0" }
}
git clone https://github.com/nicogis/externalRendererSkeleton.git
cd externalRendererSkeleton
npm install
npm test
npm run dev
npm run build
Il server di Vite abilita la sperimentazione locale e l’Hot Module Replacement; la build di produzione emette file statici in dist/. Node.js non disegna il modello 3D e non esegue il WebGL del client: a runtime sono il browser, la GPU e il SDK cartografico a lavorare.
Nel progetto la configurazione base: "./" consente di mantenere i riferimenti relativi agli asset, un aspetto importante quando la stessa build viene pubblicata sotto una sottocartella su GitHub Pages o IIS. Un plugin Vite personalizzato serve models/Soldier.glb durante lo sviluppo e lo emette in dist/models/Soldier.glb durante la build.
3. Separazione dei moduli: il rendering non deve possedere tutta l’applicazione
src/renderer.js → RenderNode, camera, GL, modello
src/character.js → AnimationMixer e gait
src/locomotion.js → tempo, velocità, distanza
src/path.js → geometria e interpolazione metrica
src/routeEditor.js → sketch, DEM e route display
src/activity.js → telemetria derivata
vite.config.js → sviluppo, packaging asset e build
Questa separazione è fondamentale. Il movimento lungo la traiettoria non dipende dalla GPU; il calcolo delle calorie non appartiene al renderer; la geometria metrica è verificabile in test isolati. In un’applicazione 3D reale, ridurre gli accoppiamenti rende più facile diagnosticare errori che altrimenti sembrano tutti semplicemente “il modello non si vede”.
4. RenderNode: input, output e framebuffer condiviso
Il cuore di src/renderer.js è la creazione di una sottoclasse RenderNode. La configurazione attuale prende e restituisce lo stage transparent-color. Non significa che Three.js possieda un canvas separato: il codice riceve l’accesso al contesto WebGL già utilizzato da ArcGIS.
const Node = RenderNode.createSubclass({
consumes: { required: ["transparent-color"] },
produces: "transparent-color",
initialize() {
this.scene3 = new THREE.Scene();
this.camera3 = new THREE.Camera();
this.camera3.matrixAutoUpdate = false;
this.camera3.matrixWorldAutoUpdate = false;
}
});
Nel primo passaggio utile del rendering, l’integrazione inizializza il renderer usando canvas e contesto ricevuti dal nodo:
this.renderer3 = new THREE.WebGLRenderer({
canvas: this.gl.canvas,
context: this.gl,
antialias: false
});
this.renderer3.autoClear = false;
Poiché WebGL è una macchina a stati, una chiamata Three.js può modificare binding e opzioni che ArcGIS usa nei passaggi successivi. Il problema non si limita allo shader attivo: comprende framebuffer, viewport, buffer, depth/stencil test, blending e altre configurazioni. Per questo l’ordine di ripristino è una parte sostanziale dell’implementazione.
// Parte cruciale della sequenza nel renderer reale
this.renderer3.resetState();
this.bindRenderTarget();
const viewport = this.gl.getParameter(this.gl.VIEWPORT);
this.renderer3.setViewport(...viewport);
this.renderer3.render(this.scene3, this.camera3);
this.resetWebGLState();
Perché quest’ordine?resetState()può intervenire sui binding GL condivisi. RichiamarebindRenderTarget()dopo il reset permette ad ArcGIS di ripristinare il framebuffer di destinazione per lo stage corrente. Nel repository esiste inoltre un adattamento disetRenderTarget()per ripristinare il target ArcGIS quando Three.js torna al target nullo: estrarre soltanto poche righe non equivale quindi a una soluzione plug-and-play.
Il progetto evita inoltre renderer.setSize() sul canvas di proprietà ArcGIS: quella chiamata può modificare le dimensioni reali del canvas, compromettendo le attachment del framebuffer. Il renderer Three.js non deve ridefinire arbitrariamente la superficie grafica gestita da SceneView.
5. Camera ArcGIS e Three.js: sincronizzare due convenzioni matriciali
Una mesh correttamente caricata e inviata alla GPU può risultare invisibile se Three.js usa matrici differenti da SceneView. La camera del SDK mette a disposizione le matrici già calcolate per la scena; l’integrazione le trasferisce nella camera Three.js. L’origine locale riduce la magnitudine delle coordinate in render space, ma richiede la corrispondente compensazione nella matrice di vista.
this.camera3.projectionMatrix.fromArray(this.camera.projectionMatrix);
this.camera3.projectionMatrixInverse
.copy(this.camera3.projectionMatrix).invert();
const viewMatrix = new THREE.Matrix4()
.fromArray(this.camera.viewMatrix);
const origin = new THREE.Matrix4()
.makeTranslation(...this.localOrigin);
this.camera3.matrixWorldInverse.multiplyMatrices(viewMatrix, origin);
this.camera3.matrixWorld
.copy(this.camera3.matrixWorldInverse).invert();
In termini geometrici, la posizione usata dalla mesh è espressa rispetto a un’origine locale. La matrice di vista deve dunque convertire correttamente quelle coordinate locali nello spazio camera. Se si sottrae l’origine alla posizione del personaggio ma non la si reinserisce nella trasformazione della camera, la proiezione risulta incoerente.
Disabilitare matrixAutoUpdate e matrixWorldAutoUpdate evita che Three.js ricostruisca le matrici della camera a partire da proprietà non sincronizzate. Una prova diagnostica utile è proiettare la posizione del modello nello spazio NDC: coordinate fuori dall’intervallo visibile indicano problemi di camera, placement o frustum prima ancora del caricamento dell’asset.
6. Coordinate GIS, origine locale e riferimento East-North-Up
La scena usa coordinate geografiche/proiettate, mentre i vertici del personaggio appartengono allo spazio locale del modello. In renderer.js si trova webgl.toRenderCoordinates per convertire una posizione geografica nello spazio di rendering e webgl.renderCoordinateTransformAt per costruire una base locale orientata est, nord e alto (ENU).
const anchor = webgl.toRenderCoordinates(
view, raisedPosition, 0, SR,
new Float64Array(3), 0, 1
);
this.root.position.set(
anchor[0] - this.localOrigin[0],
anchor[1] - this.localOrigin[1],
anchor[2] - this.localOrigin[2]
);
const enu = webgl.renderCoordinateTransformAt(
view, raisedPosition, SR, new Float64Array(16)
);
const east = new THREE.Vector3(enu[0], enu[1], enu[2]).normalize();
const north = new THREE.Vector3(enu[4], enu[5], enu[6]).normalize();
const up = new THREE.Vector3(enu[8], enu[9], enu[10]).normalize();
L’asse verticale di una scena globale non coincide necessariamente con il +Z globale del render space. Il codice ricostruisce quindi una terna locale e usa la tangente del percorso per ricavare la direzione di avanzamento, che viene proiettata sul piano tangente al terreno. Da questa terna si produce una matrice di rotazione e poi un quaternione per il nodo principale.
const forward = new THREE.Vector3()
.addScaledVector(east, tangent[0])
.addScaledVector(north, tangent[1]);
forward.addScaledVector(up, -forward.dot(up)).normalize();
const right = new THREE.Vector3().crossVectors(forward, up).normalize();
forward.crossVectors(up, right).normalize();
const rotationBasis = new THREE.Matrix4().makeBasis(right, forward, up);
const desired = new THREE.Quaternion()
.setFromRotationMatrix(rotationBasis);
Nella versione corrente viene applicata anche una correzione aggiuntiva di heading, inizialmente pari a 180°, per compensare la direzione nominale del modello. Il quaternione viene interpolato tramite slerp, evitando rotazioni istantanee quando la polilinea cambia direzione. Questo offset è una calibrazione dell’asset, non una regola cartografica generale.
Nota sulle misure: WKID 3857 è una proiezione Web Mercator: le differenze planimetriche tra coordinate non coincidono ovunque con le distanze geodetiche al suolo. Il caso di studio utilizza una metrica nello spazio proiettato coerente con i propri calcoli; per applicazioni fitness o misurazioni geografiche accurate è opportuno passare a distanze geodetiche o a un sistema locale appropriato, distinguendo anche la quota verticale.
7. Caricare Soldier.glb senza distruggere lo scheletro di animazione
Il modello viene importato con GLTFLoader. Il caricamento asincrono restituisce una scena glTF e le clip eventualmente presenti. Il progetto conserva la gerarchia interna dell’asset, la inserisce in un gruppo separato, ne calcola il bounding box e la scala rispetto a un’altezza visiva di riferimento di 2,5 metri.
const gltf = await new GLTFLoader().loadAsync(url);
this.characterHolder = new THREE.Group();
this.root.add(this.characterHolder);
this.characterHolder.add(gltf.scene);
this.animator = new CharacterAnimator(
gltf.scene, gltf.animations,
{ forward: [0, 0, 1], up: [0, 1, 0] }
);
const bounds = new THREE.Box3().setFromObject(gltf.scene);
const modelSize = bounds.getSize(new THREE.Vector3());
const scale = 2.5 / modelSize.z;
this.characterHolder.scale.setScalar(scale);
gltf.scene.position.z -= bounds.min.z;
Il gruppo characterHolder separa scala e allineamento dei piedi dall’orientamento geografico del contenitore root. È una distinzione necessaria quando un asset nasce con assi, forward vector e proporzioni differenti da quelli della scena GIS. Il codice usa CharacterAnimator per gestire AnimationMixer, selezione delle clip che contengono nomi come idle, walk o run, transizioni con fade e variazione della velocità temporale della clip.
const referenceSpeed = selected === "run"
? this.runMetersPerSecond
: this.clipMetersPerSecond;
this.active.timeScale = speedMetersPerSecond / referenceSpeed;
this.mixer.update(Math.min(Math.max(0, deltaSeconds), 0.1));
Il timeScale allinea in modo approssimativo il ritmo dell’animazione alla velocità simulata. Non elimina automaticamente foot sliding e discrepanze cinematiche: servono una calibrazione del ciclo di passo, l’eventuale gestione del root motion e verifiche sulla geometria reale del terreno.
8. Disegnare un percorso, densificarlo e campionare il DEM
L’applicazione non impone una route predefinita: l’utente disegna una polilinea sulla scena. routeEditor.js utilizza SketchViewModel, GraphicsLayer e Polyline. Per evitare segmenti troppo lunghi rispetto al terreno, la polilinea viene densificata prima di interrogare il ground DEM.
const xy = densifyPath(geometry.paths[0]);
const interpolated = new Polyline({
paths: [xy],
spatialReference: geometry.spatialReference
});
const result = await view.map.ground.queryElevation(
interpolated,
{ demResolution: "auto" }
);
const elevated = result.geometry.paths[0]
.map(p => [p[0], p[1], p[2]]);
La funzione corrente densifyPath usa un passo indicativo di 8 unità coordinate e un limite di 1.800 vertici per evitare campionamenti eccessivi. La geometria restituita viene validata: numero dei vertici coerente e coordinate finite. Una query asincrona porta anche problemi di concorrenza: il modulo usa un contatore di generazione per ignorare il risultato di richieste ormai superate dopo che l’utente ha annullato o sostituito il percorso.
Il tracciato è manuale, non generato tramite un routing engine e non vincolato automaticamente alla rete stradale. Questo va esplicitato: il Soldier percorre la polilinea disegnata e campionata, non un itinerario con snapping e costi di rete.
9. Interpolazione metrica indipendente dai frame
Un’animazione che avanza di una quantità fissa ogni frame cambia velocità con gli FPS. MetricPath prepara una sequenza cumulativa delle lunghezze dei segmenti e offre i metodi sample(distance) e tangent(distance). Locomotion aggiorna la distanza usando la velocità espressa in unità al secondo e il tempo reale di simulazione.
// Estratto da Locomotion.update(deltaSeconds)
const dt = Math.min(deltaSeconds, 0.1);
if (this.running) {
if (this.speed > 0) {
this.elapsed += dt;
this.metMinutes += metForSpeed(this.speed) * dt / 60;
}
this.distance = Math.min(
this.path.totalLength,
this.distance + this.speed * dt
);
if (this.distance >= this.path.totalLength) {
this.running = false;
this.finished = true;
}
}
return {
position: this.path.sample(this.distance),
tangent: this.path.tangent(this.distance),
distance: this.distance,
finished: this.finished
};
Il delta temporale viene limitato a 0,1 secondi. Questa scelta protegge dalle grandi discontinuità temporali dopo sospensioni o blocchi del tab, ma implica che durante gli scatti più lunghi il tempo di simulazione non recupera integralmente il tempo trascorso. È una scelta di stabilità visiva da distinguere da una simulazione che debba mantenere rigorosamente il tempo di orologio.
La tangente viene calcolata confrontando punti campionati a distanza ravvicinata lungo la polilinea. L’orientamento non dipende quindi dal solo segmento istantaneo, riducendo l’instabilità angolare nei punti di discontinuità. Il moto viene poi trasformato dalla base planimetrica al sistema locale geografico della mesh.
10. Visualizzare il percorso completato senza ricreare Graphics ogni frame
La UI offre tre modalità: Completed / remaining, Reveal as you move e Thin line. Il modulo non rimuove e ricrea continuamente i Graphic: conserva due oggetti per percorso completato e restante, aggiornandone la geometria e la visibilità quando serve. In una SceneView 3D questa scelta riduce il rischio di riavviare ripetutamente l’assemblaggio GPU degli stessi elementi.
// Strategia adottata in routeEditor.js, estratto
const remaining = new Graphic({ visible: false });
const completed = new Graphic({ visible: false });
progressLayer.addMany([remaining, completed]);
// Durante l’avanzamento: modificare gli stessi Graphics
remaining.visible = Boolean(rest);
if (rest) remaining.geometry = shape(rest);
completed.visible = Boolean(done);
if (done) completed.geometry = shape(done);
Inoltre l’aggiornamento del percorso viene evitato quando il progresso differisce meno di una piccola soglia (0,15 unità coordinate nella versione esaminata). Questo meccanismo di throttling, combinato con il riuso delle istanze, è utile per distinguere la frequenza di rendering da quella di aggiornamento delle geometrie cartografiche.
11. Activity Monitor: telemetria simulata, non sensori
Il dashboard visualizza progressione, tempo in movimento (pause escluse), distanza, velocità, passo al chilometro, ascesa, discesa e una stima di energia associata al livello MET. Le metriche non derivano da GPS, accelerometri o cardiofrequenzimetri reali. Il peso è regolabile e il campo battito cardiaco non è disponibile senza un sensore.
// Formula presente in activity.js
const integrated = metMinutes ?? metForSpeed(speed) * elapsed / 60;
const kcal = integrated * 3.5 * weight / 200;
// Dislivelli dai segmenti percorsi
const change = (p1[2] - p0[2]) * portion;
if (change > 0) ascent += change;
else descent -= change;
La formula MET è una stima illustrativa basata su ipotesi semplificate, non una misura fisiologica e non un valore clinico. La funzionalità dimostra invece un principio generale interessante: una simulazione grafica può produrre uno stato quantitativo coerente e leggibile da UI, mantenendo i calcoli in moduli puri e sottoponibili a test.
12. Diagnostica GPU: isolare RenderNode, framebuffer, proiezione e asset
Il sorgente conserva diverse modalità di diagnostica attivabili con parametri URL. Sono particolarmente importanti perché gli errori di integrazione grafica raramente generano eccezioni JavaScript esplicative: spesso si traducono soltanto in un oggetto invisibile o in un framebuffer vuoto.
| Parametro | Cosa isola |
|---|---|
?noNode | SceneView senza il RenderNode custom |
?noThree | RenderNode attivo senza chiamate WebGL Three.js |
?probe | Scrittura diretta su framebuffer mediante WebGL |
?threeProbe | Rendering di una mesh diagnostica in spazio schermo |
?geoProbe | Proiezione da posizione geografica a NDC |
?worldProbe | Mesh davanti alla camera, indipendente dal posizionamento GIS |
?placementProbe | Verifica posizionamento della mesh nel mondo |
?cubeOnly | Sostituzione del Soldier con un cubo diagnostico |
La sequenza diagnostica ragionevole è: confermare prima che SceneView funzioni, poi che il nodo venga eseguito, poi che il framebuffer riceva output, poi che Three.js sappia disegnare un oggetto semplice e infine che la proiezione geografica del modello cada nel frustum. Soltanto dopo conviene indagare le gerarchie glTF e la sincronizzazione delle clip.
Strumenti complementari: pannello Performance e Memory del browser, numero di draw calls riportato da Three.js, controlli su gl.getError(), raccolta delle matrici della camera e WebGL frame capture quando disponibile. Un quadro completo richiede benchmark ripetibili su hardware e scenari definiti: il repository non espone benchmark comparativi che giustifichino numeri di FPS dichiarati come prestazioni garantite.
13. Condivisione di GPU state e ciclo di vita delle risorse
Quando si condivide un contesto tra due librerie, la gestione delle risorse non può essere affrontata come se ciascuna fosse proprietaria dell’intera superficie. Vanno considerate le risorse create dall’applicazione Three.js, l’eventuale distruzione del nodo e la non interferenza con gli oggetti gestiti dal SDK. Il nodo implementa un metodo destroy() che libera risorse diagnostiche, ferma l’animator e richiama renderer3.dispose().
In uno sviluppo destinato alla produzione conviene verificare sistematicamente ogni geometry, material, texture e risorsa glTF creata e non più usata, oltre agli eventi UI e ai listener del layer. In particolare non bisogna assumere che il solo distacco di un Object3D liberi ogni allocazione GPU associata: la strategia di disposal deve seguire le regole dell’engine impiegato.
14. Build e distribuzione: GitHub Pages e IIS
Il workflow .github/workflows/ci.yml lancia test e build, e pubblica l’output compilato dist/ su GitHub Pages dopo gli aggiornamenti a master (o dispatch manuale). Le pull request eseguono test e build senza pubblicare. È una distinzione utile fra validazione del codice e rilascio pubblico.
Su IIS si pubblica il contenuto di dist/ nella virtual directory desiderata, non la radice dei sorgenti Vite. Il progetto copia public/web.config come dist/web.config per il corretto tipo MIME dei file GLB. I riferimenti relativi ai modelli sono costruiti rispetto a document.baseURI, così da funzionare anche sotto un path applicativo.
// Risoluzione dell’asset nel codice corrente
const modelUrl = new URL(
"models/Soldier.glb",
document.baseURI
).href;
Verifiche pratiche dopo il rilascio: risposta HTTP 200 per models/Soldier.glb, tipo MIME appropriato, assenza di richieste a URL radice errati, disponibilità degli asset ArcGIS e nessun errore di caricamento in Console/Network. Per il build locale: npm test e npm run build.
15. Limiti attuali e direzioni di ricerca
Il progetto è un caso di studio efficace, ma non è un navigation engine, un’applicazione di fitness scientificamente validata o un framework di animazione caratteri general-purpose. Restano problemi tecnici aperti o migliorabili: foot clearance sul terreno, sincronizzazione fisica del passo, comportamento della velocità sotto frame drop, stime geodetiche nel caso di percorsi estesi, e compatibilità futura delle API di rendering custom.
Una possibile evoluzione è misurare l’effetto delle opzioni grafiche su draw calls e GPU frame time, ottimizzare l’animazione per più personaggi e introdurre controlli automated sul caricamento GLB e sulle conversioni di coordinate. WebGPU è un tema distinto: non si può presupporre che una SceneView ArcGIS basata su WebGL condivida automaticamente contesto, target o risorse con un renderer WebGPU. Una migrazione richiederebbe una diversa architettura d’integrazione.
16. Conclusioni
L’insegnamento principale di externalRendererSkeleton è che una scena 3D geografica non è semplicemente una canvas in cui inserire una mesh. È un sistema composto da pipeline di rendering, camera, transform space, terreno, asset animati e logica temporale. Separare questi livelli, renderli osservabili e riprodurre i problemi con probe semplici consente di costruire soluzioni solide anche quando ArcGIS e Three.js condividono il medesimo contesto WebGL.
Il caso dimostra anche il valore della toolchain moderna: ES modules, Vite, test eseguibili in Node.js, build riproducibile e pubblicazione statica. Il risultato è un laboratorio concreto per sviluppatori GIS che vogliono integrare rendering 3D personalizzato nella scena ArcGIS senza perdere controllo sul comportamento grafico.
Risorse e codice
- Repository open source externalRendererSkeleton
- Demo interattiva GitHub Pages
- Renderer e integrazione RenderNode
- Gestione delle animazioni
- Editor percorsi e DEM
- ArcGIS Maps SDK for JavaScript: documentazione
- Three.js Documentation
- Vite Guide
NicoGIS Tech Weekly · 20 ottobre 2026. Analisi tecnica riferita alla versione del repository esaminata per la stesura. Le API, le dipendenze e i sorgenti possono evolvere. Le misure di attività incluse nell’esempio sono simulate.
Nessun commento:
Posta un commento