SVG-Pfade in WebGL rendern

Avatar of Matt DesLauriers
Matt DesLauriers am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Der folgende Beitrag ist ein Gastbeitrag von Matt DesLauriers. Matt hat eine Reihe von Techniken und Open-Source-Modulen kombiniert, um eine unglaubliche Demo zu erstellen, bei der einfache, erkennbare Icons in Vektordreiecke explodieren, nur um sich dann zu einem völlig neuen Icon neu zu formen. Hier spricht er über einige der Werkzeuge und Ideen, die dabei eine Rolle spielten, aber auch über andere Ansätze und verwandte Konzepte. Das ist ziemlich fortgeschrittene Materie und ich hoffe, sie wird bei Leuten, die sich für diese Art von mathematischer, algorithmischer Web-Animation interessieren, einige mentale Glühbirnen zum Leuchten bringen. Bitte übernehmen Sie, Matt.

SVG ist eine großartige Möglichkeit, geräteunabhängige Icons, Schriftarten, Logos und verschiedene andere Bilder zu liefern. Das Format ist weit verbreitet, sehr flexibel und sehr kompakt. Zum Beispiel kann das neue Google-Logo in SVG nur 146 Bytes groß sein, mit SVG und ein paar Tricks.

Das Herzstück des Formats ist das <path>-Element, das eine prägnante Möglichkeit bietet, eine Reihe von Pfadoperationen zu beschreiben, wie z. B. ein Glyphen in einem Zeichensatz. Vereinfacht gesagt, beschreibt es eine Reihe von moveTo-, lineTo-, curveTo- und ähnlichen Befehlen. Hier ist zum Beispiel das Google "G" in SVG

<path d="M173 102a51 51 0 1 1-13-30m20 37h-53" stroke="#4a87ee"/>

Siehe den Pen QjMrXV von Matt DesLauriers (@mattdesl) auf CodePen.

In WebGL ist das Rendern von SVG-Pfaden jedoch anspruchsvoller. Die WebGL-API und damit auch ThreeJS ist hauptsächlich für das Rendern vieler Dreiecke konzipiert. Es liegt am Entwickler, Aufgaben wie die komplexe Formendarstellung und das Textlayout zu implementieren.

Hier werde ich einige der Ansätze zur Verarbeitung des SVG-<path>-Elements in WebGL untersuchen und kurz die Werkzeuge besprechen, die in meiner neuesten Demo für das svg-mesh-3d Modul verwendet wurden. Wenn Sie einen WebGL-fähigen Browser haben, können Sie die Demo hier sehen.

Implementierung

Die Codebeispiele hier verwenden ThreeJS, um die WebGL-API zu wrappen, und browserify, um kleine npm-Module zusammenzufassen. Weitere Details zu diesem Ansatz finden Sie im Leitfaden Modules for Frontend JavaScript.

Entwicklungswerkzeuge

Während der Entwicklung der ThreeJS-Demo habe ich budo als Entwicklungsserver und babelify zur Transpilierung von ES6 verwendet. Dies führt zu einem einfachen und schnellen Entwicklungszyklus, selbst bei großen Bundles. brfs wird verwendet, um Shader-Dateien und unsere Liste statischer SVG-Dateien zum Rendern einzubetten. Anstatt Gulp oder Grunt zu verwenden, besteht die gesamte Entwicklung und Produktion aus zwei npm run Aufgaben in einer package.json Datei.

"scripts": {
  "start": "budo demo/:bundle.js --live -- -t babelify -t brfs | garnish",
  "build": "browserify demo/index.js -t babelify -t brfs | uglifyjs -cm > bundle.js"
}

Das gh-pages-Deployment wird dann mit einem einzigen Shell-Skript automatisiert.

Weitere Details zu npm run finden Sie in „How to Use npm as a Build Tool“ von Keith Cirkel.

Approximation & Triangulierung

Für meine svg-mesh-3d Demo musste ich nur eine einfache Silhouette der <path>-Daten mit einer Volltonfarbe rendern. Entypo icons passt gut dazu, und die Triangulierung funktioniert gut für diese Art von SVGs.

Der Ansatz, den ich verfolgt habe, ist, die Kurven in einem SVG-Pfad zu approximieren, seine Konturen zu triangulieren und die Dreiecke als statische Geometrie an WebGL zu senden. Dies ist ein teurer Schritt. Man macht das nicht jedes Frame, aber nachdem die Dreiecke auf der GPU liegen, kann man einen Vertex-Shader verwenden, um sie frei zu animieren.

Der Großteil dieser Arbeit wurde bereits durch verschiedene Module auf npm erledigt. Der „Klebstoff“, der sie zusammenbringt, ist weniger als 200 Codezeilen.

Die finale ThreeJS-Demo verwendet über 70 Module in ihrem Bundle, stützt sich aber stark auf einige der folgenden im Hintergrund

  • parse-svg-path, das eine SVG <path>-Zeichenkette in eine Reihe von Operationen parst.
  • normalize-svg-path, das alle Pfadsegmente in kubische Bézier-Kurven umwandelt.
  • adaptive-bezier-curve, das Bézier-Kurven adaptiv auf eine bestimmte Skalierung unterteilt, unter Verwendung des Algorithmus in Anti-Grain Geometry.
  • simplify-path zur Vereinfachung der Konturen und zur Reduzierung der Triangulierungszeit.
  • clean-pslg zur Bereinigung der Pfaddaten vor der Triangulierung.
  • cdt2d zur Berechnung der Constrained Delaunay 2D-Triangulierung.
  • three-simplicial-complex zur Umwandlung generischer Pfaddaten in eine ThreeJS-Geometrie.

Letztendlich wird die Benutzererfahrung so einfach wie das Einbinden des svg-mesh-3d-Moduls und die Manipulation der von ihm zurückgegebenen Daten. Das Modul ist nicht spezifisch für ThreeJS und könnte mit jeder Render-Engine wie Babylon.js, stackgl, Pixi.js und sogar Vanilla Canvas2D verwendet werden. Hier ist, wie es in ThreeJS verwendet werden könnte

// our utility functions
var createGeometry = require('three-simplicial-complex')(THREE);
var svgMesh3d = require('svg-mesh-3d');

// our SVG <path> data
var svgPath = 'M305.214,374.779c2.463,0,3.45,0.493...';

// triangulate to generic mesh data
var meshData = svgMesh3d(svgPath);

// convert the mesh data to THREE.Geometry
var geometry = createGeometry(meshData);

// wrap it in a mesh and material
var material = new THREE.MeshBasicMaterial({
  side: THREE.DoubleSide,
  wireframe: true
});

var mesh = new THREE.Mesh(geometry, material);

// add to scene
scene.add(mesh);

Vertex-Animation

Wenn Sie mit WebGL arbeiten, ist es am besten, Daten nicht zu oft auf die GPU hochzuladen. Das bedeutet, dass wir versuchen sollten, statische Geometrie *einmal* zu erstellen und sie dann über viele Frames durch Vertex- und Fragment-Shader zu animieren.

Um unser Mesh in winzige Stücke „explodieren“ zu lassen, können wir ein Uniform in einem Shader ändern, was ein bisschen wie eine Variable für GLSL ist. Der Vertex-Shader wird für jeden Vertex unserer 3D-Geometrie ausgeführt und ermöglicht es uns, uns vom Weltursprung bei [ x=0, y=0, z=0 ] weg zu bewegen.

Dazu benötigen wir ein benutzerdefiniertes Vertex-Attribut in unserem Shader. Für jedes Dreieck verwenden seine drei Eckpunkte denselben Vector3, um eine direction zu beschreiben. Diese direction ist ein zufälliger Punkt auf einer Sphäre.

Der minimale Vertex-Shader unten transformiert lediglich die position.xyz jedes Eckpunkts mit seiner direction.xyz, skaliert durch den animation-Faktor. Die position.xyz ist im Modellraum im Bereich von -1,0 bis 1,0.

attribute vec3 direction;
uniform float animation;

void main() {
  // transform model-space position by explosion amount
  vec3 tPos = position.xyz + direction.xyz * animation;

  // final position
  gl_Position = projectionMatrix * modelViewMatrix * vec4(tPos, 1.0);
}

Wenn das Uniform bei 0,0 liegt, befinden sich die Dreiecke an ihrer ursprünglichen Position und das Logo ist eine Volltonfüllung.

Wenn das Uniform auf 1,0 gesetzt ist, werden die Dreiecke entlang des Richtungsvektors vom Weltzentrum weg geschoben und bilden eine Art „Explosion“.

Das sieht gut aus, aber es gibt noch zwei weitere Features für einige polierende Effekte. Das erste ist, das Dreieck auf seinen Schwerpunkt zu skalieren. Dies wird verwendet, um die Dreiecke unabhängig von der Explosion ein- und auszublenden. Dafür müssen wir ein weiteres benutzerdefiniertes Vertex-Attribut übergeben, so wie wir es mit direction gemacht haben.

Das zweite ist, der Explosion etwas Spin und Chaos hinzuzufügen, indem der direction.xyz-Vektor durch eine Rotationsmatrix transformiert wird. Der Winkel für die Rotationsmatrix wird durch den animation-Faktor bestimmt, sowie durch das sign() der x-Position des Dreiecksschwerpunkts. Das bedeutet, dass sich einige Dreiecke in entgegengesetzter Richtung drehen.

Mit dem finalen Vertex-Shader kann unsere Anwendung nun Tausende von Dreiecken mit geschmeidigen 60 FPS animieren. Unten ist ein Screenshot mit Drahtgitter-Rendering und dem randomization-Parameter auf 1500 gesetzt.

Andere Ansätze

Ich habe cdt2d gewählt, da es numerisch robust ist, beliebige Eingaben mit Löchern verarbeiten kann und engine-unabhängig ist. Andere Triangulatoren wie earcut, tess2 und der eigene Triangulator von ThreeJS wären ebenfalls gute Optionen gewesen.

Abgesehen von der Triangulierung ist es erwähnenswert, andere Ansätze für das Rendern von SVG-<path> in WebGL. Kurvenapproximation und Triangulierung haben Nachteile: Es ist langsam, die Geometrie zu erstellen, es ist schwierig, es korrekt zu tun, es erhöht die Größe des endgültigen Code-Bundles und es zeigt „Treppenstufen“ oder gezackte Kanten, wenn man zoomt.

Rasterisierung

Ein einfacher und effektiver Ansatz ist, SVG-Daten in ein HTMLImageElement zu rasterisieren und dieses dann in eine WebGL-Textur hochzuladen. Dies unterstützt nicht nur das <path>-Element, sondern die gesamte Bandbreite an SVG-Features, die der Browser abdeckt, wie Filter, Muster, Text und sogar HTML/CSS in Firefox und Chrome.

Siehe zum Beispiel svg-to-image, wie dies mit Blob und URL.createObjectURL erfolgen kann. In ThreeJS und Browserify könnte der Code so aussehen

// our rasterizing function
var svgToImage = require('svg-to-image');

// create a box with a dummy texture
var texture = new THREE.Texture();
var geo = new THREE.BoxGeometry(1, 1, 1);
var mat = new THREE.MeshBasicMaterial({
  map: texture
});

// add it to the scene
scene.add(new THREE.Mesh(geo, mat));

// convert SVG data into an Image
svgToImage(getSvgData(), {
  crossOrigin: 'Anonymous'
}, function (err, image) {
  if (err) {
    // there was a problem rendering the SVG data
    throw new Error('could rasterize SVG: ' + err.message);
  }

  // no error; update the WebGL texture
  texture.image = image;
  texture.needsUpdate = true;
});

function getSvgData () {
  // make sure that "width" and "height" are specified!
  return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="1024px" height="1024px">.....</svg>';
}

Ergebnis

Da wir die Grafik jedoch in Pixel rasterisieren, ist sie nicht mehr skalierbar und erzeugt beim Hineinzoomen Aliasing. Oft benötigen Sie große Bilder, um glatte Kanten zu erzeugen, was die Texturspeicher stark beansprucht.

Dies erzeugt keine Dreiecke. Um die „Explosion“ nachzuahmen, könnten wir eine einfache Delaunay-Triangulierung verwenden, die viel schneller und einfacher ist als die Constraint-Delaunay-Triangulierung.

Stencil-Buffer

Ein alter Trick ist die Verwendung des Stencil-Buffers zum Rendern komplexer Polygone mit Löchern. Dies ist viel schneller als der Triangulierungsschritt, der in meiner Demo involviert ist, hat aber einen großen Nachteil: Er bietet in den meisten Browsern kein MSAA (Anti-Aliasing). Einige Engines verwenden dies möglicherweise in Verbindung mit anderen Techniken wie FXAA (Anti-Aliasing in einem Post-Process) oder durch Hinzufügen einer Umrandung zur Form mit vorgefilterten Linien.

Pixi.js verwendet dies für die Darstellung komplexer Formen, eine Demo hier.

Eine komplexe Vektorgrafik, gerendert mit dem Stencil-Buffer

Hier können Sie mehr darüber lesen

Loop-Blinn-Kurven-Rendering

Ein fortgeschrittenerer Ansatz für die Hardware-Pfad-Darstellung wird in Resolution Independent Curve Rendering von Loop und Blinn beschrieben. Eine zugänglichere Einführung finden Sie in „Curvy Blues“ von Michael Dominic.

Diese Technik liefert die beste Kurven- und Pfad-Darstellung. Sie ist schnell, unendlich skalierbar und kann die GPU für Anti-Aliasing und Spezialeffekte nutzen. Die Implementierung ist jedoch wesentlich komplexer. Es gibt viele Randfälle, die bei beliebigen SVG-Pfaden auftreten können, wie z. B. überlappende oder sich selbst schneidende Kurven. Außerdem ist sie patentiert und daher keine gute Wahl für ein Open-Source-Projekt.

Weitere Lektüre

Dieser Beitrag kratzt nur an der Oberfläche der skalierbaren Vektorgrafiken in WebGL. Das SVG-Format geht weit über gefüllte Pfade hinaus und umfasst Features wie Verläufe, Linien, Text und Filter. Jedes davon führt zu neuen Komplexitäten im WebGL-Bereich, und die vollständige Darstellung des Formats auf der GPU wäre eine gewaltige Menge Arbeit.

Weitere Lektüre zu einigen dieser Themen finden Sie unter