Se hvordan Max koda 3D-grafikk for Karpe

Max Melander i Blank forklarer hvordan og hvorfor han skapte effektene på sasskien.no med sanntidsgrafikk i WebGL, WebAudio og React.

Max Melander kaller seg teknolog hos Blank, og har utviklet 3D-grafikken som åpenbarer seg utover i prosessen på sasskien.no, hvor deltakere kan forsøke å vinne billetter til intimkonserter med Karpe. 📸: Privat
Max Melander kaller seg teknolog hos Blank, og har utviklet 3D-grafikken som åpenbarer seg utover i prosessen på sasskien.no, hvor deltakere kan forsøke å vinne billetter til intimkonserter med Karpe. 📸: Privat Vis mer

Hallois.

Før nyttår fikk jeg lov å være med på å lage kule ting til Karpe sine Spektrum 2021- og Skien 2020-prosjekter.

Spesifikt påmeldingsløsningen, hvor en person med billett til Spektrum har muligheten til å søke om å bli valgt ut til å få en helt unik opplevelse i Skien.

Front-enden ble laget med WebGL, WebAudio og React. Du kan sjekke ut løsningen på www.sasskien.no.

I dette innlegget vil jeg gå gjennom implementasjonen av sanntidsgrafikken og til slutt diskutere verdien av sanntidsgrafikk på interwebben.

«Tenk kontrasten mellom 2000-talls webdesign og The Upside Down fra Stranger Things blandet med DOS-estetikk.»

#1. Motivasjon

Målet her var ikke bare å lage en teknisk solid løsning hvor folk kan registrere seg og svare på spørsmål. Det måtte også være en opplevelse i seg selv som bygget spenning og forventninger til hva som skal skje i Skien.

Vi landet på å lage en parodi av en billettkjøpsflyt, hvor brukeren underveis oppdager at ting ikke er like basic og skyblått som de først skulle tro. Tenk kontrasten mellom 2000-talls webdesign og The Upside Down fra Stranger Things blandet med DOS-estetikk.

Inspirasjon.
Inspirasjon. Vis mer

Stian Sanderholm fra Rebell som laget 3d-modellene til dette prosjektet laget også et referansebilde i Cinema 4D som fanget estetikken vi ville ha i scenen vår.

C4D Test Render.
C4D Test Render. Vis mer

#2. Implementering

Det viktigste å vite før du leser videre er at i en graphics pipeline har man både faste og programmerbare steg.

De to programmerbare stegene vi bryr oss mest om er vertex shaders og fragment shaders. En vertex shader er et lite program som kjører for hvert hjørne i polygonene våre. En fragment shader kjører på samme måte for hvert fragment (tenk pixel). Data som sendes fra vertex shaderen blir interpolert før det havner i fragment shaderen.

Wireframe

Med Stians referansebilde som utgangspunkt satt jeg først opp modellene med Phong shading og noen enkle punktlys. For å tegne ting som wireframes utvidet jeg THREE.js sine Phong shaders med teknikken funnet her og her.

Fra phong shading til wireframe.
Fra phong shading til wireframe. Vis mer

For hver trekant i modellen la jeg til koordinatene (1,0,0), (0,1,0) og (0,0,1). Ett koordinat i hvert hjørne.

// graphicsUtils.js
export const addBarycentricCoordinates = geometry => {
  let nIndices = geometry.attributes.position.count;
  let nTriangles = nIndices / 3;
  let baryArray = new Float32BufferAttribute(
    geometry.attributes.position.count * 3,
    3);
  geometry.addAttribute("barycentric", baryArray);
  let aVec = new Vector3(1.0, 0.0, 0.0);
  let bVec = new Vector3(0.0, 1.0, 0.0);
  let cVec = new Vector3(0.0, 0.0, 1.0);
  let index = 0;
  for (var i = 0; i < nTriangles; i++) {
    index = i * 9;
    aVec.toArray(baryArray.array, index);
    bVec.toArray(baryArray.array, index + 3);
    cVec.toArray(baryArray.array, index + 6);
  }
  return geometry;
};

.

Bilde hentet fra catlikecoding.com/unity/tutorials/advanced-rendering/flat-and-wireframe-shading/.
Bilde hentet fra catlikecoding.com/unity/tutorials/advanced-rendering/flat-and-wireframe-shading/. Vis mer

I fragment-shaderen endte jeg da opp med et interpolert koordinat som har komponenter som er 0 langs en kant og 1 i hjørnet vis-à-vis denne kanten. Har vi en komponent som er lik 0 vet vi at vi er på en kant. Slike koordinater kalles barycentric coordinates, og kan enkelt brukes til å farge fragmenter basert på distansen til en kant.

// wireframe.fsh
varying vec3 vBarycentric; // Interpolated from the vertex shader
uniform float uWireframeSmoothing;
uniform float uWireframeThickness;
uniform vec3 uWireframeColor;
uniform float uGlowStrength;
vec3 delta = fwidth(vBarycentric);
vec3 smoothing = delta * uWireframeSmoothing;
vec3 thickness = delta * uWireframeThickness;
vec3 bary = smoothstep(
 thickness, 
 thickness + smoothing, 
 vBarycentric);
// We only care about the closest edge, 
// so extract the smallest component
float minBary = min(bary.x, min(bary.y, bary.z));
vec3 albedo = vec3(0.0); 
vec3 wireResult = mix(uWireframeColor, albedo, minBary);
// phongResult is the resulting color of THREE's phong shading
vec3 result = clamp(wireResult * (max(phongResult, glowStrength)), 0.0, 1.0);
gl_FragColor = vec4(result, 1.0);

Å legge til koordinater som attributter per vertex betyr at vi ikke kan dele vertices mellom trekantene i modellen vår. Utenfor verdensveven kunne vi ha lagt til disse koordinatene i en geometry shader, men siden vi ikke har tilgang til det graphics-pipeline-steget i WebGL må vi leve med mye duplisert data her.

Refleksjoner

For å få scenen til å føles ut som et ekte sted, og ikke bare to modeller som flyter rundt i verdensrommet, la jeg til en bakke med reflekterende sølepytter under huset. Et hus kan jo ikke stå på ingenting, si. Les mer om denne type refleksjoner her og her.

Her reflekterer vi først kameraposisjonen rundt bakkeplanet og rendrer scenen ut i 2 off-screen textures, en i høy oppløsning, og en i lav oppløsning som også blurres. I tillegg til disse sender vi også inn en maske og normal map til bakke-shaderen.

Maske (venstre). Normal map (høyre). Lånt fra https://threejs.org/examples.
Maske (venstre). Normal map (høyre). Lånt fra https://threejs.org/examples. Vis mer

I de fragmentene som faller utenfor masken henter vi fargedata fra den lavoppløslige texturen, mens i de som faller innenfor henter vi fra den med høy oppløsning. Dette betyr at selv de ikke-reflekterende delene av bakken fortsatt reflekterer litt lys.

Jeg bruker en normal map til å simulere effekten av at vannet buler opp av bakken pga overflatespenning ved å bøye refleksjonsvektoren rundt vannkanten.

// ground.fsh
vec4 texelMask = texture2D( maskMap, gv );
vec3 res = vec3(0.0);
if (texelMask.a <= 0.4) {
  // Sample from the low res texture
  vec4 roughBase = texture2DProj( tRoughDiffuse, vUv );    
  res = vec3(roughBase.rgb * 0.75);
} else {
  // Sample from the high res texture
  // using the normal map to bend our reflection vector
  vec4 reflect = vUv;
  vec3 normal = (texture2D( normalMap, gv ).rgb - 0.5) * 2.0;

  reflect.x += normal.y;
  reflect.y += normal.x;
  reflect.z += normal.z;

  vec4 base = texture2DProj( tDiffuse, reflect);  
  res = base.rgb;
}
gl_FragColor = vec4(res, 1.0);

Bloom

For å få de lyse delene av bildet til å oppleves som om de faktisk lyser, implementerte jeg en bloom-effekt. Bloom simulerer den glødende kameralinse-effekten hvor lys blør utover kantene. Få en skikkelig innføring her og her.

image: Se hvordan Max koda 3D-grafikk for Karpe

Det første steget i en bloom-effekt er å skille ut alle delene av bildet som er over en gitt lysstyrke. Vi transformerer fragmentets farge til gråskala ved å finne indreproduktet av vår originale farge mot vektede luminansekoeffissienter. Grunnen til vektingen er at rødt, blått og grønt ikke påvirker vår oppfattelse av lysstyrke likt. Hvis den utregna gråfargen er større enn en gitt terskelverdi returnerer vi den originale fargen, ellers returnerer vi svart.

uniform sampler2D inputBuffer;
uniform float luminosityThreshold;
uniform float smoothWidth;

varying vec2 vUv;

void main() {
  vec4 defaultColor = vec4(0.0);
  vec4 texel = texture2D( inputBuffer, vUv );
  
  vec3 luma = vec3( 0.299, 0.587, 0.114 ); 
  float v = dot( texel.xyz, luma );

  float alpha = smoothstep( luminosityThreshold, luminosityThreshold + smoothWidth, v );

  vec4 result = mix( defaultColor, texel, alpha );

  gl_FragColor = result;
}

Etter at vi har våre lyse deler skilt ut slædder vi det med en two-pass gaussian blur. Vi kunne ha stoppet der og addert resultatet tilbake med originalbildet, men for å treffe den skumle og mørke visuelle estetikken litt bedre la jeg også på en dirt mask, inspirert av Unreal Engine som har samme feature.

Dirt Mask.
Dirt Mask. Vis mer

.

// bloom.fsh
// ...
vec4 dirtTexel = texture2D(dirtMask, uv);
vec4 dirt = bloomResult * (dirtTexel.r * 3.0);
vec4 result = (bloomResult + (dirt * 2.7)) * 0.8;

Dette gir helheten en mer realistisk følelse, som om man ser gjennom en kameralinse. Vi kan jo late som vi har løpt gjennom sølepyttene og litt vann og dritt har sprutet opp på kameralinsa vår. Det er gøy å late som.

Før bloom:

Etter bloom:

Støy

Så langt ser bildet litt for rent og pent ut i forhold til Stranger Things VHS-estetikken vi er ute etter. Analog video har ofte litt støy og sortnivået går sjelden helt ned til 0.

Jeg brukte Michaelangel007 sin pseudo-random funksjon fra stackoverflow som tar en vec2 som input og returnerer et tall mellom 0 og 1, skalerte ned resultatet og adderte det til originalfargen. I tillegg til at dette får bildet vårt til å se støyete ut vil det også få opp sortnivået siden nesten ingen fragmenter lenger vil være helt sorte.

// post.fsh
float random(vec2 p) {
  vec2 K1 = vec2(
    23.14069263277926, // e^pi (Gelfond's constant)
    2.665144142690225 // 2^sqrt(2) (Gelfond–Schneider constant)
  );
  
  return fract( cos( dot(p,K1) ) * 12345.6789 );
}
...
result.rgb += vec3(random(uv)*0.10);

RGB Split

For å ta oss enda dypere inn i VHS-land, la jeg også på en RGB split-effekt.

Hvis vi hadde lagt denne effekten jevnt på hele bildet ville det fort ha blitt litt distraherende, så jeg skalerer en offset-verdi slik at den blir større jo lenger ut mot skjermkantene vi er. Deretter sampler jeg rødt, grønt og blått separat med vår offset lagt til teksturkoordinatet.

// post.fsh
float uvx = abs((uv.x * 2.) - 1.);
float uvy = abs((uv.y * 2.) - 1.);
float offset = 0.006 * clamp(uv.x + uv.y, 0., 1.);
float r = texture2D(inputBuffer, uv + offset).r;
float g = texture2D(inputBuffer, uv).g;
float b = texture2D(inputBuffer, uv - offset).b;

Før RGB split:

Etter RGB split:

Lyskasteren

På dette punktet var den største mangelen i forhold til testbildet, og noe man sjelden ser en UFO uten, en lysstråle fra magan til skipet.

På grunn av mangel på tid og siden vi tross alt er på internett og skal fungere på mange forskjellige enheter, var jeg ikke helt up for å begynne med “ekte” volumetrisk lys. Så jeg prøvde å fake det i stedet.

Fra kjegle til lys.
Fra kjegle til lys. Vis mer

Jeg begynte med å smelle inn en hvit kjegle under UFOen. Denne er helt hvit og påvirkes ikke av lyset i scenen. Ikke et veldig overbevisende resultat.

Jeg implementerte deretter en God Ray-shader. God Rays er enda en klassisk og veldokumentert effekt som immiterer effekten av lys som beveger seg gjennom et medium som røyk eller tåke. Det fungerer veldig likt Bloom, men med litt mer retningsbestemt blurring.

Videre prøvde jeg å slenge på et bilde av røyk som en texture til kjegla. Resultatet ble overraskende overbevisende!

#3. Hvorfor

Så hva er vitsen? Hvorfor kunne jeg ikke bare fått Stian til å rendre ut en sykt fet video som uten tvil ville holdt en høyere visuell standard enn hva jeg klarte å få til her?

En av de største fordelene med en sanntidsløsning er definitivt interaktivitet, feedback og timing. Jeg ville lage en opplevelse der brukeren føler at de selv har funnet en hemmelig verden skjult bak en tilsynelatende kjip nettside.

En stor faktor i å få til det — etter min mening — er at brukeren må føle at deres valg betyr noe og påvirker hva de ser.

Det samme gjelder personalisering av innhold. Vi kan enkelt plassere brukerens navn inn i 3d-scenen.

Når brukeren drar i en slider begynner UFOen å snurre fortere og fortere mens lyden av motoren øker i frekvens. Som bruker skjønner man ikke hva alt dette betyr, men det gir en følelse av at svarene dine har en direkte invirkning på hva du ser, og at det er du selv som styrer opplevelsen. Vi får også mye igjen for all innsats vi la inn i grafikkoden, siden noe så enkelt som å putte 2d-tekst inn i scenen ser fett ut med refleksjoner, bloom osv.

En stor teknisk fordel er filstørrelser i forhold til kvalitet. Det eneste her som tar no særlig plass er 3d-modellene. Huset tar 374kb, mens UFOen — som har et mye høyere detaljnivå — tar 9.2mb. Hvis man skulle oppnådd lignende resultater med video, måtte man ha lagd mange videosnutter som alle looper perfekt og har perfekte overganger.

Jeg er ikke no komprimeringsfyr, men om man skal matche kvaliteten til en sanntidsløsning er jeg ganske sikker på at man ville endt opp på mer enn 10mb.

Å rendre ut videoer tar også lang tid. Det er vanskelig å gjøre endringer og man blir låst til valgene sine tidligere. Dette prosjektet endret seg mye underveis, helt frem til uken før release.

Å ikke være låst til forhåndslagde videosnutter, og ha muligheten til å justere ting hele veien, var ekstremt verdifullt.

«Å ikke være låst til forhåndslagde videosnutter, og ha muligheten til å justere ting hele veien, var ekstremt verdifullt.»

#4. Referanser

Det finnes mange gode læringsressurser der ute om du er interessert i grafikkprogrammering. Her er de jeg har fått mest ut av til dette prosjektet.

Takk til Eirik Rathe og Ole Jacob Eriksen Syrdahl.