Crtanje 3D oblika
Updated: 2024-12-14
Published: 2024-12-14
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:
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]);
Jer su ideksi raspoređeni redoslijedom kojim želimo pobojati stranice, lagano je definirati vrijednosti međuspremnika za boje:
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)),
]
})();
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:
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;
}
precision mediump float;
varying vec3 color;
void main() {
gl_FragColor = vec4(color, 1.0);
}
Također je definirana u_View
uniformna varijabla kojom možemo primjeniti željenu transformaciju na kocku.
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);
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.
// 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
Time dobivamo sljedeću transformacijsku matricu:
source
document.getElementById("cubeTransform").innerText = formatMatrix(V);
Kada primjenimo dobivenu matricu na kocku, možemo vidjeti i njene druge stranice.
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.
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")
)
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:
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);
Definiramo indekse tetraedra kako bi mogli duplicirati njegove vrhove:
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)),
];
Shader ostaje isti.
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")
)
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:
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);
Zatim svakom vrhu dodjeljujemo boju:
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),
];
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:
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()
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:
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),
]
})();
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;
}
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();
})
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);