Crtanje 3D oblika

Updated: 2024-12-14

Published: 2024-12-14


remote ESMhttps://caellian.github.io/blog/2024/webgl_3d/webgl-debug.mjs remote ESMhttps://caellian.github.io/blog/2024/webgl_3d/gl_matrix/index.js remote ESMhttps://caellian.github.io/blog/2024/webgl_3d/gfx.js embedded JS
source
// Uklonio sam ArticleScope is ESM modulea, pa redefiniram canvasContext da
// koristi ArticleScope s ove objave.
// Sada se ./gfx.js može koristiti i van okruženja mog bloga.
function init(canvas, options = {}) {
  return canvasContext(canvas, {
    ...options,
    mode: "3D", // koristimo 3D grafiku u ovoj objavi
    vertex: options.vertex || window.ArticleScope.VERTEX_SHADER,
    fragment: options.fragment || window.ArticleScope.FRAGMENT_SHADER,
  });
};

Za ove vježbe sam sa vectorious prešao na gl-matrix biblioteku jer je vectorious column-major a WebGL row-major, i to mi je prethodno uzrokovalo muke. gl-matrix ima veći fokus na performanse i složena je upravo za primjenu s WebGLom te radi s poljima određenih tipova podataka (engl. Typed Arrays).

Ima dosta prednosti, i potpunija je biblioteka, iako zahtjeva malo više koda. vectorious je još dosta nova biblioteka i ima dosta pogrešaka u njoj, a s obzirom da je i moj kod za ovaj kolegij u izradi, teško mi je bilo odrediti griješim li ja ili se radi o bugu i biblioteci koji koristim.

Zadatak 1: crtanje kocke i njena rotacija

Jer različite stranice kocke želimo pobojati različitim bojama, potrebno je duplicirati sve vrhove 3 puta. Postoje složenija rješenja koja se oslanjaju na računanje normala stranica u fragment shaderu, no po zadanoj JS datoteci vidim da je očekivano dupliciranje. Ovaj dio mi je oduzeo dosta vremena na kolokviju jer nisam bio upoznat s quad funkcijom jer sam zadaću pisao bez gledanja u popratne materijale za vježbu.

U biti, quad radi sljedeće:

embedded JS
js
const CUBE_VERTICES = [
    vec3.fromValues(-0.5, -0.5,  0.5), // 0
    vec3.fromValues( 0.5, -0.5,  0.5), // 1
    vec3.fromValues( 0.5,  0.5,  0.5), // 2
    vec3.fromValues(-0.5,  0.5,  0.5), // 3
    vec3.fromValues(-0.5, -0.5, -0.5), // 4
    vec3.fromValues( 0.5, -0.5, -0.5), // 5
    vec3.fromValues( 0.5,  0.5, -0.5), // 6
    vec3.fromValues(-0.5,  0.5, -0.5), // 7
];
const CUBE_INDICES = [
    // Front               // Back
    0, 1, 2, 0, 2, 3,      4, 6, 5, 4, 7, 6,
    // Left                // Right
    0, 3, 4, 3, 7, 4,      1, 5, 6, 1, 6, 2,
    // Top                 // Bottom
    3, 2, 6, 3, 6, 7,      5, 1, 0, 4, 5, 0,
];
const FLAT_CUBE = CUBE_INDICES.map(i => CUBE_VERTICES[i]);
12345678910111213141516171819

Jer su ideksi raspoređeni redoslijedom kojim želimo pobojati stranice, lagano je definirati vrijednosti međuspremnika za boje:

embedded JS
js
const CUBE_COLORS = (() => {
  const third = CUBE_INDICES.length / 3;
  return [
    ...Array(third).fill(vec3.fromValues(1.0, 0.0, 1.0)),
    ...Array(third).fill(vec3.fromValues(0.0, 1.0, 0.0)),
    ...Array(third).fill(vec3.fromValues(0.0, 0.0, 1.0)),
  ]
})();
12345678

Dodajemo a_Color u shadere kako bismo mogli prikazati svaku stranicu u drugoj boji. A uporabom varying proslijeđujemo boju iz vertex shadera u fragment shader:

VERTEX_SHADERvert
attribute vec3 a_Position;
attribute vec3 a_Color;

uniform mat4 u_View;

varying vec3 color;

void main() {
  gl_Position = u_View * vec4(a_Position, 1.0);
  color = a_Color;
}
1234567891011
FRAGMENT_SHADERfrag
precision mediump float;

varying vec3 color;

void main() {
  gl_FragColor = vec4(color, 1.0);
}
1234567

Također je definirana u_View uniformna varijabla kojom možemo primjeniti željenu transformaciju na kocku.

Vaš preglednik ne podržava Canvas elemente za prikaz embedded JS
js
const gl = init("#zad1");

const position = vertexBuffer(gl, "a_Position", Format.Vec3F);
position.set(FLAT_CUBE);
position.enable();
const color = vertexBuffer(gl, "a_Color", Format.Vec3F);
color.set(CUBE_COLORS);
color.enable();

// transformacija koju ćemo kasnije definirati, za sada je identiteta
const T = uniform(gl, "u_View", Format.Mat4F);
gl.drawArrays(gl.TRIANGLES, 0, position.length);
123456789101112

Na prikazanoj kocki se vidi samo prednja stranica te trebamo primjeniti projekciju i tražene transformacije. Prilikom izrade projekcije ćemo pomnožiti left i right plohe s omjerom širine i visine kako kocka nebi izgledala izduljeno. Time izbjegavamo potrebu za <canvas> elementom jednakih dimenzija.

embedded JS
js
// projekcija
const orthoFor = (gl) => {
  const aspect = gl.canvas.width / gl.canvas.height;
  return mat4.orthoNO(
    mat4.create(),
    -1 * aspect, // left
    1 * aspect,  // right
    1,           // bottom
    -1,          // top
    0.001,       // near
    1000,        // far
  )
}
let P = orthoFor(gl);
// pogled
const onlyRotation = (rotation) => mat4.fromRotationTranslationScale(
  mat4.create(),
  rotation,
  vec3.fromValues(0, 0, -10),
  vec3.fromValues(1, 1, 1),
);
let V = onlyRotation(quat.fromEuler(quat.create(), 30, 10, 0));
mat4.multiply(V, P, V);
// računamo umnožak matrica na procesoru
123456789101112131415161718192021222324

Time dobivamo sljedeću transformacijsku matricu:

embedded JS
source
document.getElementById("cubeTransform").innerText = formatMatrix(V);

Kada primjenimo dobivenu matricu na kocku, možemo vidjeti i njene druge stranice.

embedded JS
source
function doCubeTransform() {
  gl.canvas.scrollIntoView({ behavior: "smooth" });
  const startRotation = quat.create();
  const targetRotation = quat.fromEuler(quat.create(), 30, 10, 0);
  
  T.set(V);
  setTimeout(() => {
    fractionTime((t) => {
      let rotation = quat.lerp(quat.create(), startRotation, targetRotation, t);
      V = onlyRotation(rotation);
      mat4.multiply(V, P, V);
      T.set(V);
      gl.clear(); // moja init funkcija vrati izmjenjeni WebGL2RenderingContext
      // tako da ne trebam navesti bitove za clearanje je default vrijednosti ovise
      // o kontekstu crtanja (2d - color; 3d - color & depth).
      gl.drawArrays(gl.TRIANGLES, 0, position.length);
    }, 2000);
  }, 500);
}
document.getElementById("applyCubeTransform").onclick = doCubeTransform;

Zadatak 2: dodavanje interaktivnosti

Uz malo dodatnog koda, možemo učiniti rotaciju kocke potpuno interaktivnom.

embedded JS
js
const gl = init("#zad2");
const position = vertexBuffer(gl, "a_Position", Format.Vec3F);
position.set(FLAT_CUBE);
position.enable();
const color = vertexBuffer(gl, "a_Color", Format.Vec3F);
color.set(CUBE_COLORS);
color.enable();

// transformacija koju ćemo kasnije definirati, za sada je identiteta
const T = uniform(gl, "u_View", Format.Mat4F);
const P = orthoFor(gl);
T.set(P);
gl.drawArrays(gl.TRIANGLES, 0, position.length);

const step = 0.0007;
function drawWithArrays(gl, n) {
  gl.drawArrays(gl.TRIANGLES, 0, n)
}
function setupRotation(gl, T, n, x, y, z, draw = drawWithArrays) {
  const rotation = quat.create();
  let currentAxis = quat.rotateX;
  animation((deltaTime) => {
    if (gl.visible) {
      currentAxis(rotation, rotation, step * deltaTime);
      const V = onlyRotation(rotation);
      mat4.multiply(V, P, V);
      T.set(V);
    }
    gl.clear();
    draw(gl, n);
  });

  x.onclick = () => {
    currentAxis = quat.rotateX;
  }
  y.onclick = () => {
    currentAxis = quat.rotateY;
  }
  z.onclick = () => {
    currentAxis = quat.rotateZ;
  }
}
setupRotation(
  gl, T, position.length,
  document.getElementById("xButton"),
  document.getElementById("yButton"),
  document.getElementById("zButton")
)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
Vaš preglednik ne podržava Canvas elemente za prikaz

Osi rotacije su relativne na virtualni predmet jer ažuriram isti kvaternion. Kada bi umjesto toga njega množio s dodatnom rotacijom s lijeva, konačna rotacija bi bila relativna na globalne osi.

Zadatak 3: crtanje tetraedra i interpoliranje boje u poligonu

Definiramo vrhove i boje tetraedra:

embedded JS
js
const t60 = Math.sqrt(3);
const H = 0.5 * t60;
const VERTICES = [
  vec3.fromValues(-0.5, -0.5, 0.5),
  vec3.fromValues(0.5, -0.5, 0.5),
  vec3.fromValues(0, -0.5, 0.5 - H),
];
const inner = Math.sqrt(0.25 + (1 - H) * (1 - H));
const height = inner * t60;
VERTICES.push(vec3.fromValues(0, height - 0.5, 0.5 - H + inner));
// pomakne sve vrhove za (ishodište - geometrijska sredina)
centerVertices(VERTICES);
123456789101112

Definiramo indekse tetraedra kako bi mogli duplicirati njegove vrhove:

embedded JS
js
const INDICES = [
  0, 1, 2, 1, 0, 3, 2, 1, 3, 0, 2, 3
];
const REGULAR_TETRAHEDRON = INDICES.map(i => VERTICES[i]);
const REGULAR_TETRAHEDRON_COLORS = [
  ...Array(3).fill(vec3.fromValues(1.0, 0.5, 0)),
  ...Array(3).fill(vec3.fromValues(0.2, 0.8, 0.2)),
  ...Array(3).fill(vec3.fromValues(0.0, 0.5, 0.5)),
  ...Array(3).fill(vec3.fromValues(1.0, 0.5, 1.0)),
];
12345678910

Shader ostaje isti.

Vaš preglednik ne podržava Canvas elemente za prikaz
embedded JS
js
const gl = init("#zad3-1");
const position = vertexBuffer(gl, "a_Position", Format.Vec3F);
position.set(REGULAR_TETRAHEDRON);
position.enable();
const color = vertexBuffer(gl, "a_Color", Format.Vec3F);
color.set(REGULAR_TETRAHEDRON_COLORS);
color.enable();

const T = uniform(gl, "u_View", Format.Mat4F);
const P = orthoFor(gl);
T.set(P);
gl.drawArrays(gl.TRIANGLES, 0, position.length);

setupRotation(
  gl, T, position.length,
  document.getElementById("xButton1"),
  document.getElementById("yButton1"),
  document.getElementById("zButton1")
)
12345678910111213141516171819

Za drugi dio ovog zadatka je smisleno koristiti međuspremnik elemenata jer je svakom vrhu pridružena jedinstvena boja. Njega postavljamo na isti način kao i međuspremnike za atribute vrhova, samo je potrebno paziti da pohranjeni podaci odgovaraju GLenum tipu podatka na koji primjenjujemo drawElements.

Za početak definiramo mrežu iglastog tetraedra:

embedded JS
js
const POINTED_TETRAHEDRON = [
  ...VERTICES.slice(0, -1).map(vec3.clone), // potrebno kopiranje zbog centriranja
  vec3.fromValues(0, height + 0.5, 0.5 - H + inner),
];
centerVertices(POINTED_TETRAHEDRON);
12345

Zatim svakom vrhu dodjeljujemo boju:

embedded JS
js
const POINTED_TETRAHEDRON_COLORS = [
  vec3.fromValues(0, 1, 1),
  vec3.fromValues(1, 0, 1),
  vec3.fromValues(1, 1, 0),
  vec3.fromValues(0.5, 0.5, 0.5),
];
123456
embedded JS
source
const gl = init("#zad3-2");
const position = vertexBuffer(gl, "a_Position", Format.Vec3F);
position.set(POINTED_TETRAHEDRON);
position.enable();
const color = vertexBuffer(gl, "a_Color", Format.Vec3F);
color.set(POINTED_TETRAHEDRON_COLORS);
color.enable();
const T = uniform(gl, "u_View", Format.Mat4F);
const P = orthoFor(gl);
T.set(P);

Koristit ćemo prethodno definirani popis indeksa INDICES kao vrijednosti međuspremnika elemenata:

embedded JS
js
const elements = elementBuffer(gl, Format.U8);
elements.set(INDICES);
// ne trebamo pozivati gl.enableVertexAttribArray jer se podrazumijeva
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, elements.buffer);
// ili nadalje elements.bind()

gl.drawElements(gl.TRIANGLES, 0, gl.UNSIGNED_BYTE, elements.length);
// ili nadalje elements.draw()
12345678
Vaš preglednik ne podržava Canvas elemente za prikaz
embedded JS
source
setupRotation(
  gl, T, position.length,
  document.getElementById("xButton2"),
  document.getElementById("yButton2"),
  document.getElementById("zButton2"),
  (gl, n) => {
    elements.draw()
  }
)

Zadatak 4: krnja piramida

Krnju piramidu možemo jednostavno konstruirati od postojećih vrhova kocke, tako što pomaknemo gordnje vrhove (s pozitivnijim Y koordinatama) bliže Y osi:

embedded JS
js
const SCALE_XZ = mat4.fromValues(
  0.5, 0, 0, 0,
  0, 0.3, 0, 0,
  0, 0, 0.5, 0,
  0, 0, 0, 1,
);
const TRUNCATED_PYRAMID_VERTICES = CUBE_VERTICES.map(vec3.clone).map((v) => {
  if (v[1] > 0) {
    vec3.transformMat4(v, v, SCALE_XZ)
  }
  return v;
});
centerVertices(TRUNCATED_PYRAMID_VERTICES);
// indeksi ostaju isti kao i za kocku
const TRUNCATED_PYRAMID = CUBE_INDICES.map(i => TRUNCATED_PYRAMID_VERTICES[i]);
const TP_COLORS = (() => {
  const face = CUBE_INDICES.length / 6;
  const c = (r, g, b) => vec3.fromValues(r / 255, g / 255, b / 255);
  const fc = (r, g, b) => Array(face).fill(c(r, g, b));
  return [
    ...fc(51, 186, 204),
    ...fc(81, 145, 255),
    ...fc(255, 120, 219),
    ...fc(192, 193, 255),
    ...fc(108, 83, 187),
    ...fc(210, 155, 61),
  ]
})();
12345678910111213141516171819202122232425262728
VERTEX_SHADERvert
attribute vec3 a_Position;
attribute vec3 a_Color;

uniform mat4 u_Projection;
uniform mat4 u_Model;

varying vec3 color;

void main() {
  gl_Position = u_Projection * u_Model * vec4(a_Position, 1.0);
  color = a_Color;
}
123456789101112
Vaš preglednik ne podržava Canvas elemente za prikaz
embedded JS
js
const gl = init("#zad4");
const points = vertexBuffer(gl, "a_Position", Format.Vec3F)
  .set(TRUNCATED_PYRAMID)
  .enable();
vertexBuffer(gl, "a_Color", Format.Vec3F)
  .set(TP_COLORS)
  .enable();
const aspect = gl.canvas.width / gl.canvas.height;
uniform(gl, "u_Projection", Format.Mat4F).set(
  mat4.multiply(
    mat4.create(),
    mat4.perspectiveNO(
      mat4.create(),
      Math.PI / 3, // fovy
      aspect,        // aspect ratio
      0.001,         // near
      1000           // far
    ),
    mat4.fromTranslation(mat4.create(), vec3.fromValues(0, 0, -2))
  )
);
const M = uniform(gl, "u_Model", Format.Mat4F);

let xState = 0;
let yState = 0;
let rotationState = mat4.create();
const SENSITIVITY = 0.3;
function addRotation(x, y) {
  let xState = x * SENSITIVITY;
  let yState = y * SENSITIVITY;
  const change = mat4.fromQuat(
    mat4.create(),
    quat.fromEuler(quat.create(), yState, xState, 0)
  );
  mat4.multiply(rotationState, change, rotationState);
}
const autorotation = animation(() => {
  addRotation(5, 2);
}, { fps: 30 });
animation(() => {
  M.set(rotationState);
  gl.clear();
  points.draw();
})
1234567891011121314151617181920212223242526272829303132333435363738394041424344
embedded JS
source
const trackpoint = document.getElementById("trackpoint");
function moveListener(e) {
  const x = e.clientX;
  const y = e.clientY;
  const deltaX = x - this.lastX;
  const deltaY = (y - this.lastY) * -1;
  addRotation(deltaX, -deltaY);
  this.lastX = x;
  this.lastY = y;
}
function setupMoveHandler(e) {
  autorotation.stop();
  document.body.style.cursor = "all-scroll";
  document.body.style.userSelect = "none";
  const state = {
    lastX: e.clientX,
    lastY: e.clientY,
  };
  const listener = moveListener.bind(state);
  document.addEventListener("mousemove", listener);
  document.addEventListener("mouseup", function resetState() {
    autorotation.start();
    document.body.style.cursor = null;
    document.body.style.userSelect = null;
    document.removeEventListener("mousemove", listener);
    document.removeEventListener("mouseup", resetState);
    document.removeEventListener("mousedown", resetState);
    document.removeEventListener("scroll", resetState); // zoom?
    trackpoint.addEventListener("mousedown", setupMoveHandler);
  });
  trackpoint.removeEventListener("mousedown", setupMoveHandler);
}
trackpoint.addEventListener("mousedown", setupMoveHandler);

Comments