Bagaimana saya mencapai efek permukaan bertekstur 2D-ke-3D di Ceramics, proyek NFT seni generatif pertama saya.
Selamat datang di posting blog teknis mendalam pertama tentang cara saya membuat Keramik, proyek seni generatif NFT pertama saya.
Sedikit bertanya sebelum kita mulai: Saya akan sangat berterus terang dengan aspek teknis dari proyek ini, jauh lebih banyak daripada yang dapat Anda pahami dari mempelajari kode sumber. Anda dipersilakan untuk membangun teknik ini untuk proyek Anda sendiri, tetapi tolong kembangkan dengan cukup sehingga tidak menyerupai Keramik. Proyek peniru tidak keren, dan saya membagikan informasi ini dengan itikad baik yang akan dipelajari dan dipahami pembaca daripada disalin. Selanjutnya!
#Dasar-dasar teknis
Konsep awal saya untuk Keramik adalah memiliki permukaan datar yang entah bagaimana diukir, tetapi saya tidak tahu bagaimana saya bisa mencapai efek 3D itu dengan kode.
Vertex shader tampak seperti area eksplorasi yang jelas karena secara khusus untuk membangun permukaan 3D, tetapi kemudian saya harus menentukan kedalaman seluruh permukaan menggunakan rumus matematika dan saya tidak dapat mendekati goresan bergelombang dan tumpang tindih ada dalam pikiran saya. Saya yakin ada cara untuk membuat proyek ini dengan shader (Sketsa asli Camille Roux cukup dekat!) tapi bukan itu keahlian saya.
Ide kedua yang saya miliki adalah menggunakan three.js, saya telah menggunakan three.js sebelumnya di Hexatope dan saya suka betapa mudahnya membuat materi fisik yang tampak realistis. Ide saya adalah membuat serangkaian tabung dan menggunakan operasi boolean untuk mengukirnya dari kotak padat; sayangnya, three.js tidak memiliki fungsionalitas geometri padat yang konstruktif dan pustaka three-csg yang direkomendasikan memiliki kinerja yang buruk dengan jumlah tabung yang eksponensial, jadi ini juga tidak boleh digunakan.
Ide saya berikutnya terasa seperti tembakan panjang dari awal — menggambar goresan pada kanvas 2D dan memetakan warna setiap piksel ke sumbu z titik dalam jaring. Saya berasumsi kinerja metode ini akan mengerikan karena menghitung normal (vektor tegak lurus) dari setiap simpul dan pencahayaan/bayangan banyak yang harus ditangani oleh GPU, tetapi ternyata cukup masuk akal untuk gambar statis (pasangan detik untuk output persegi 1000px).
Canvas API adalah tempat yang menyenangkan bagi saya sehingga menemukan metode ini adalah wahyu, artinya saya dapat memiliki kendali penuh atas komposisi dengan JavaScript dan dengan mudah mengubahnya menjadi pemandangan 3D.
Berikut beberapa contoh kode cara kerja metode ini:
.js
import * as THREE from 'three'
const width = 1000
const height = 1000
const maxDepth = 100
// we need the depth data for one unit wider than the output image
const columns = width + 1
const rows = height + 1
// setup renderer
const renderer = new THREE.WebGLRenderer()
renderer.setSize(width, height)
document.body.appendChild(renderer.domElement)
const scene = new THREE.Scene()
const camera = new THREE.OrthographicCamera(
-width / 2,
width / 2,
height / 2,
-height / 2,
0,
1000
)
camera.position.set(0, 0, 500)
// create canvas
const canvas = document.createElement('canvas')
canvas.width = columns
canvas.height = rows
const c = canvas.getContext('2d')
// draw whatever you'd like on the canvas
c.fillStyle = 'black'
c.fillRect(0, 0, columns, rows)
c.filter = 'blur(100px)'
c.fillStyle = 'white'
c.beginPath()
c.arc(width / 2, width / 2, width / 2, 0, Math.PI \* 2)
c.fill()
// construct plane and set the z-axis of each vertex to the pixel's depth
const plane = new THREE.PlaneGeometry(width, height, width, height)
const depthData = c.getImageData(0, 0, columns, rows).data
const positionAttribute = plane.getAttribute('position')
for (let i = 0, count = positionAttribute.count; i < count; i++)
// the depthData is an array of RGBA values
// we're taking the red channel which has the values 0-255
positionAttribute.setZ(i, (depthData[i * 4] / 255) \* maxDepth)
// compute the normals of each vertex based on the triangles they're
// connected to, this makes the lighting reflect accurately
positionAttribute.needsUpdate = true
plane.computeVertexNormals()
// add plane to the scene and render
const material = new THREE.MeshNormalMaterial()
const mesh = new THREE.Mesh(plane, material)
scene.add(mesh)
renderer.render(scene, camera)
.js
import * as THREE from 'three'
const width = 1000
const height = 1000
const maxDepth = 100
// we need the depth data for one unit wider than the output image
const columns = width + 1
const rows = height + 1
// setup renderer
const renderer = new THREE.WebGLRenderer()
renderer.setSize(width, height)
document.body.appendChild(renderer.domElement)
const scene = new THREE.Scene()
const camera = new THREE.OrthographicCamera(
-width / 2,
width / 2,
height / 2,
-height / 2,
0,
1000
)
camera.position.set(0, 0, 500)
// create canvas
const canvas = document.createElement('canvas')
canvas.width = columns
canvas.height = rows
const c = canvas.getContext('2d')
// draw whatever you'd like on the canvas
c.fillStyle = 'black'
c.fillRect(0, 0, columns, rows)
c.filter = 'blur(100px)'
c.fillStyle = 'white'
c.beginPath()
c.arc(width / 2, width / 2, width / 2, 0, Math.PI \* 2)
c.fill()
// construct plane and set the z-axis of each vertex to the pixel's depth
const plane = new THREE.PlaneGeometry(width, height, width, height)
const depthData = c.getImageData(0, 0, columns, rows).data
const positionAttribute = plane.getAttribute('position')
for (let i = 0, count = positionAttribute.count; i < count; i++)
// the depthData is an array of RGBA values
// we're taking the red channel which has the values 0-255
positionAttribute.setZ(i, (depthData[i * 4] / 255) \* maxDepth)
// compute the normals of each vertex based on the triangles they're
// connected to, this makes the lighting reflect accurately
positionAttribute.needsUpdate = true
plane.computeVertexNormals()
// add plane to the scene and render
const material = new THREE.MeshNormalMaterial()
const mesh = new THREE.Mesh(plane, material)
scene.add(mesh)
renderer.render(scene, camera)
#Menggambar goresan halus
Sekarang saya telah menemukan bagaimana saya akan membuat pemandangan 3D yang saya butuhkan untuk menentukan apa yang akan saya gambar di atas kanvas. Saya memutuskan untuk menggunakan medan aliran yang dimodifikasi untuk memandu bentuk dan pola sapuan, yang akan saya bahas lebih detail di artikel berikutnya, tetapi saya perlu menggambarnya di kanvas dengan cara yang akan diterjemahkan dengan baik ke dalam 3D.
Berikut adalah berbagai metode yang saya kerjakan untuk mengetahuinya, bersama dengan demo interaktif.
#Lingkaran dengan opasitas rendah
Pada titik-titik reguler di sepanjang goresan, gambarlah sebuah lingkaran dengan opacity rendah. Lingkaran menyatu untuk membentuk kurva halus.
Cuplikan kode
.js
c.globalAlpha = 0.05
points.forEach(([x, y, thickness]) =>
c.beginPath()
c.arc(x, y, thickness / 2, 0, Math.PI * 2)
c.fill()
)
.js
c.globalAlpha = 0.05
points.forEach(([x, y, thickness]) =>
c.beginPath()
c.arc(x, y, thickness / 2, 0, Math.PI * 2)
c.fill()
)
Ini adalah metode yang sangat murah untuk membuat goresan halus dan efektif ketika titik-titiknya berdekatan tetapi tidak ada cara untuk mengontrol profil goresan – tepi goresan sangat tajam ketika lingkaran cukup dekat untuk terlihat halus.
#Garis dengan opasitas rendah
Hubungkan titik-titik goresan pada jalur dan gambar garis dengan opacity rendah yang semakin tebal.
Cuplikan kode
.js
// use lighten blend mode so the lightest colour always wins
// the middle of the stroke wouldn't be white if we used alpha instead
c.globalCompositeOperation = 'lighten'
const path = new Path2D()
path.moveTo(points[0][0], points[0][1])
points.slice(1).forEach(([x, y]) =>
path.lineTo(x, y)
)
for (let t = 0; t < 1; t += 1 / (steps + 0.5))
c.strokeStyle = `rgb($255 * t, $255 * t, $255 * t)`
c.lineWidth = thickness * (1 - t)
c.stroke(path)
.js
// use lighten blend mode so the lightest colour always wins
// the middle of the stroke wouldn't be white if we used alpha instead
c.globalCompositeOperation = 'lighten'
const path = new Path2D()
path.moveTo(points[0][0], points[0][1])
points.slice(1).forEach(([x, y]) =>
path.lineTo(x, y)
)
for (let t = 0; t < 1; t += 1 / (steps + 0.5))
c.strokeStyle = `rgb($255 * t, $255 * t, $255 * t)`
c.lineWidth = thickness * (1 - t)
c.stroke(path)
Sekali lagi ini adalah metode yang sangat performan menggambar stroke dan profil stroke dapat dikontrol dengan mengubah opacity atau lebar setiap langkah. Namun, goresan tidak dapat memiliki lebar variabel dan kami tidak dapat mengontrol bentuk awal dan akhir setiap goresan.
# Gradien radial
Metode ini juga menggunakan lingkaran di sepanjang garis tetapi masing-masing diisi dengan gradien radial.
Cuplikan kode
.js
c.globalCompositeOperation = 'lighten'
const gradient = c.createRadialGradient(0, 0, 0, 0, 0, 100)
gradient.addColorStop(0, 'white')
gradient.addColorStop(1, 'black')
c.fillStyle = gradient
points.forEach(([x, y, thickness]) =>
c.save()
c.translate(x, y)
// the gradient is a finite size so we use scale to change its size
c.scale(thickness / 200, thickness / 200)
c.beginPath()
c.arc(0, 0, 100, 0, Math.PI * 2)
c.fill()
c.restore()
)
.js
c.globalCompositeOperation = 'lighten'
const gradient = c.createRadialGradient(0, 0, 0, 0, 0, 100)
gradient.addColorStop(0, 'white')
gradient.addColorStop(1, 'black')
c.fillStyle = gradient
points.forEach(([x, y, thickness]) =>
c.save()
c.translate(x, y)
// the gradient is a finite size so we use scale to change its size
c.scale(thickness / 200, thickness / 200)
c.beginPath()
c.arc(0, 0, 100, 0, Math.PI * 2)
c.fill()
c.restore()
)
Ini bekerja dengan baik ketika titik-titiknya berdekatan tetapi ketika diberi jarak di tengah setiap gradien menjadi terlihat dan membuat goresan terlihat bergerigi. Dengan metode ini kita dapat mengontrol kedalaman dan profil goresan dengan memanipulasi gradien.
#Gradien linear tersegmentasi
Metode selanjutnya yang saya coba jauh lebih rumit secara teknis. Untuk setiap titik saya menghitung titik tegak lurus di tepi goresan dan menggambar poligon yang tumpang tindih dengan gradien linier yang tegak lurus dengan goresan.
Cuplikan kode
.js
const points = inputPoints.map((point, i) =>
const [x, y, thickness] = point
// get the angle by averaging the angle of the previous and next point
const a = i > 0 ? inputPoints[i - 1] : point
const b = i < inputPoints.length - 1 ? inputPoints[i + 1] : point
const angle = Math.atan2(b[1] - a[1], b[0] - a[0])
const left =
x: x + Math.cos(angle + Math.PI / 2) * thickness * 0.5,
y: y + Math.sin(angle + Math.PI / 2) * thickness * 0.5,
const right =
x: x + Math.cos(angle - Math.PI / 2) * thickness * 0.5,
y: y + Math.sin(angle - Math.PI / 2) * thickness * 0.5,
return
x,
y,
thickness,
angle,
left,
right,
)
c.globalCompositeOperation = 'lighten'
const gradient = c.createLinearGradient(0, 100, 0, -100)
gradient.addColorStop(0, 'black')
gradient.addColorStop(0.5, 'white')
gradient.addColorStop(1, 'black')
c.fillStyle = gradient
points
.slice(1, points.length - 1)
.forEach(( x, y, thickness, angle, left, right , i) =>
const prev = points[i]
const next = points[i + 2]
const distLeftPrev = Math.sqrt(
(left.x - prev.left.x) ** 2 + (left.y - prev.left.y) ** 2
)
const distRightPrev = Math.sqrt(
(right.x - prev.right.x) ** 2 + (right.y - prev.right.y) ** 2
)
const distLeftNext = Math.sqrt(
(left.x - next.left.x) ** 2 + (left.y - next.left.y) ** 2
)
const distRightNext = Math.sqrt(
(right.x - next.right.x) ** 2 + (right.y - next.right.y) ** 2
)
c.save()
// move to the center segment
c.translate(x, y)
c.rotate(angle)
// draw an approximate rectangle covering the area of the segment
// because we've already translated and rotated (which we need for the gradient to work)
// we can't use the actual point vectors unless we also translate and rotate them
// for simplicity in this demo I've chosen to approximate it insetad
c.beginPath()
c.moveTo(-distLeftPrev, thickness)
c.lineTo(distLeftNext, thickness)
c.lineTo(distRightNext, -thickness)
c.lineTo(-distRightPrev, -thickness)
c.closePath()
c.scale(thickness / 100, thickness / 100)
c.fill()
c.restore()
)
.js
const points = inputPoints.map((point, i) =>
const [x, y, thickness] = point
// get the angle by averaging the angle of the previous and next point
const a = i > 0 ? inputPoints[i - 1] : point
const b = i < inputPoints.length - 1 ? inputPoints[i + 1] : point
const angle = Math.atan2(b[1] - a[1], b[0] - a[0])
const left =
x: x + Math.cos(angle + Math.PI / 2) * thickness * 0.5,
y: y + Math.sin(angle + Math.PI / 2) * thickness * 0.5,
const right =
x: x + Math.cos(angle - Math.PI / 2) * thickness * 0.5,
y: y + Math.sin(angle - Math.PI / 2) * thickness * 0.5,
return
x,
y,
thickness,
angle,
left,
right,
)
c.globalCompositeOperation = 'lighten'
const gradient = c.createLinearGradient(0, 100, 0, -100)
gradient.addColorStop(0, 'black')
gradient.addColorStop(0.5, 'white')
gradient.addColorStop(1, 'black')
c.fillStyle = gradient
points
.slice(1, points.length - 1)
.forEach(( x, y, thickness, angle, left, right , i) =>
const prev = points[i]
const next = points[i + 2]
const distLeftPrev = Math.sqrt(
(left.x - prev.left.x) ** 2 + (left.y - prev.left.y) ** 2
)
const distRightPrev = Math.sqrt(
(right.x - prev.right.x) ** 2 + (right.y - prev.right.y) ** 2
)
const distLeftNext = Math.sqrt(
(left.x - next.left.x) ** 2 + (left.y - next.left.y) ** 2
)
const distRightNext = Math.sqrt(
(right.x - next.right.x) ** 2 + (right.y - next.right.y) ** 2
)
c.save()
// move to the center segment
c.translate(x, y)
c.rotate(angle)
// draw an approximate rectangle covering the area of the segment
// because we've already translated and rotated (which we need for the gradient to work)
// we can't use the actual point vectors unless we also translate and rotate them
// for simplicity in this demo I've chosen to approximate it insetad
c.beginPath()
c.moveTo(-distLeftPrev, thickness)
c.lineTo(distLeftNext, thickness)
c.lineTo(distRightNext, -thickness)
c.lineTo(-distRightPrev, -thickness)
c.closePath()
c.scale(thickness / 100, thickness / 100)
c.fill()
c.restore()
)
Meskipun kode untuk metode ini lebih panjang dan menggunakan lebih banyak kekuatan pemrosesan untuk menggambar, titik-titiknya bisa berjauhan dan tetap menghasilkan goresan yang mulus.
Saya sedang mempertimbangkan untuk menggunakan metode gradien radial sampai saya memiliki ide tentang alat beralur khusus yang hanya dapat dilakukan dengan menggunakan gradien linier. Solusi pamungkas saya menggunakan segmen gradien linier ini tetapi dengan lebih banyak matematika untuk memposisikan dan menskalakan gradien dan mengontrol penurunan awal dan akhir setiap pukulan.
Keuntungan menggunakan gradien adalah saya dapat men-tweak gradien untuk mengubah profil goresan. Saya menggunakan bezier kubik khusus untuk mengontrol gradien dan membuat berbagai bentuk alat. Anda dapat bereksperimen dengan bezier alat dalam demo ini dan melihat pengaruhnya terhadap kualitas permukaan 3D.
Cuplikan kode
.ts
import CubicBezier from '@thednp/bezier-easing'
const createSymmetricalGradient = (
c: CanvasRenderingContext2D,
bezier: [number, number, number, number]
): CanvasGradient =>
const easing = new CubicBezier(...bezier)
const lightness = []
for (var t = 0; t <= 1; t += 0.02)
lightness.push(easing._at
const linearGradient = c.createLinearGradient(0, 100, 0, -100)
lightness.forEach((l, i) =>
linearGradient.addColorStop(
i / (lightness.length - 1) / 2,
`rgb($l * 255, $l * 255, $l * 255)`
)
linearGradient.addColorStop(
1 - i / (lightness.length - 1) / 2,
`rgb($l * 255, $l * 255, $l * 255)`
)
)
return linearGradient
.ts
import CubicBezier from '@thednp/bezier-easing'
const createSymmetricalGradient = (
c: CanvasRenderingContext2D,
bezier: [number, number, number, number]
): CanvasGradient =>
const easing = new CubicBezier(...bezier)
const lightness = []
for (var t = 0; t <= 1; t += 0.02)
lightness.push(easing._at
const linearGradient = c.createLinearGradient(0, 100, 0, -100)
lightness.forEach((l, i) =>
linearGradient.addColorStop(
i / (lightness.length - 1) / 2,
`rgb($l * 255, $l * 255, $l * 255)`
)
linearGradient.addColorStop(
1 - i / (lightness.length - 1) / 2,
`rgb($l * 255, $l * 255, $l * 255)`
)
)
return linearGradient
Alat beralur menggunakan gradien linier berulang dan kedalaman tetap, jumlah alur tergantung pada ketebalan goresan.
Cuplikan kode
.ts
import CubicBezier from '@thednp/bezier-easing'
const createGroovedGradient = (
c: CanvasRenderingContext2D,
grooves: number
): CanvasGradient =>
const easing = new CubicBezier(0.1, 0, 0.6, 1)
const lightness = []
for (var t = 0; t <= 1; t += 0.02)
lightness.push(easing._at
const linearGradient = c.createLinearGradient(0, 100, 0, -100)
lightness.forEach((l, i) =>
for (let grooveI = 0; grooveI < grooves; grooveI++)
const through = i / (lightness.length - 1) / 2 / grooves
linearGradient.addColorStop(
grooveI / grooves + through,
`rgb($l * 255, $l * 255, $l * 255)`
)
linearGradient.addColorStop(
(grooveI + 1) / grooves - through,
`rgb($l * 255, $l * 255, $l * 255)`
)
)
return linearGradient
.ts
import CubicBezier from '@thednp/bezier-easing'
const createGroovedGradient = (
c: CanvasRenderingContext2D,
grooves: number
): CanvasGradient =>
const easing = new CubicBezier(0.1, 0, 0.6, 1)
const lightness = []
for (var t = 0; t <= 1; t += 0.02)
lightness.push(easing._at
const linearGradient = c.createLinearGradient(0, 100, 0, -100)
lightness.forEach((l, i) =>
for (let grooveI = 0; grooveI < grooves; grooveI++)
const through = i / (lightness.length - 1) / 2 / grooves
linearGradient.addColorStop(
grooveI / grooves + through,
`rgb($l * 255, $l * 255, $l * 255)`
)
linearGradient.addColorStop(
(grooveI + 1) / grooves - through,
`rgb($l * 255, $l * 255, $l * 255)`
)
)
return linearGradient
#Masalah jangkauan
Semuanya tampak sangat menjanjikan, tetapi saya menemukan masalah saat mengekspor dengan resolusi tinggi; pada goresan datar besar Anda bisa melihat tonjolan di render. Karena kita memetakan kanvas ke kedalaman piksel, hanya ada 256 kedalaman yang ditetapkan (setiap saluran warna bisa antara 0-255, dan kita hanya menggunakan saluran merah), jadi ketika gradiennya dangkal, kontur muncul di antara setiap kedalaman dan dilemparkan ke dalam kelegaan oleh pencahayaan yang dramatis.
Saya pikir ini akan menjadi hal yang mudah untuk diperbaiki; jika saya hanya menggunakan saluran merah, tidak bisakah saya juga menggunakan saluran hijau dan biru dan rentang tiga kali lipat dari 256 menjadi 768? Masalahnya adalah mengubah dari hitam menjadi putih akan menambah setiap saluran dengan kecepatan yang sama, jadi akan berubah dari 0 menjadi 3 menjadi 6 jika saya menambahkan saluran bersama-sama.
Saya punya ide bahwa mungkin saya bisa mengimbangi saluran entah bagaimana dengan melapiskan gradien warna yang berbeda, tetapi dengan cepat menghindar dari itu karena akan memusingkan 😅 Sebaliknya, bagaimana jika saya menggunakan warna yang tidak putih sehingga setiap saluran akan kenaikan pada tingkat yang berbeda.
Menggunakan rgb(253, 254, 255)
memiliki kisaran 0-764 yang bagus, tetapi langkah-langkahnya tidak dibuat sama – di awal dan akhir gradien, peningkatan saluran hampir sinkron sehingga langkah-langkahnya masih terlihat di area yang kemungkinan besar akan kita kunjungi Lihat mereka.
Untuk mengetahui warna ideal saya membuat CodePen yang membuat gradien selebar 10.000 piksel dan menghitung jumlah langkah yang berbeda serta seberapa ‘lebar’ setiap langkah. Tujuan saya adalah menemukan warna dengan rentang yang besar tetapi juga langkah-langkah yang relatif merata, terutama di awal dan akhir gradien. Saya menggunakan pena saya untuk menghitung standar deviasi lebar langkah dan mencoba beberapa warna acak.
saya mendarat rgb(50, 250, 255)
, aqua yang menyenangkan. Saya tidak dapat mengumpulkan pertahanan mengapa saya memilih warna yang tepat itu, tetapi memiliki penyebaran langkah yang baik tanpa mengorbankan terlalu banyak jangkauan, dan secara besar-besaran mengurangi visibilitas langkah.
Saya harap Anda menikmati pengintipan ini ke dalam penyiapan teknis di balik Ceramics. Mengerjakan proyek ini telah memperluas keterampilan pemecahan masalah saya lebih dari apa pun yang pernah saya lakukan, dan sungguh luar biasa bisa berbagi sebagian dari proses itu dengan Anda!
Saya akan memposting penyelaman yang lebih dalam tentang proyek ini di minggu-minggu berikutnya, dan pengumuman tanggal rilis sudah dekat. Ikuti saya di Twitter untuk tetap berada dalam lingkaran.
# Suka dan komentar
Kelly Milligan jawab :
Tulisan yang bagus! Suka matikan 2D ke 3D. Bersemangat untuk melihat ini tiba 💪
Matt McDonnel jawab :
Eric De Giuli (EDG) jawab :
Dingin! Menantikan rilisnya. Btw: beralih dari satu saluran ke 3 saluran sebenarnya meningkatkan jangkauan Anda menjadi 256*256*256, jauh lebih banyak daripada menambahkannya. Hanya perlu membuat float sebagai c.r+256*c.g+256*256*cb dll
Karena cuma dapat bergantung kepada pihak yang sedia kan knowledge togel sidney saja yang bisa mendapatkan knowledge sgp lengkap. Lantas bersama sulit nya membuka situs togel singapore pools terhadap negara +62. Maka alangkah baiknya berlangganan terhadap halaman ini untuk menemukan data keluaran sgp hari ini live tercepat hanya disini.