Trực quan hóa các đám mây điểm với Three.js

Trong một thời gian, tôi đã làm việc trên một dự án nội bộ mà đầu ra của nó trong số nhiều thứ khác cũng là các đám mây điểm. Khi tôi nghĩ về công cụ trực quan thích hợp của những đám mây đó, tôi đã lập một danh sách những thứ mà nó có thể thực hiện:
- tùy biến
- Khả năng vượt qua các trình đổ bóng đỉnh/đoạn tùy chỉnh
- Dễ dàng chia sẻ
Như mọi khi, dự án phụ này không chỉ dừng lại ở việc hình dung. Vì tôi cũng muốn làm cho điều này trở nên thú vị với những người khác, nên trong 15 phút tới, tôi sẽ cho bạn biết điều gì đó về bản đồ độ sâu / MiDaS, nhận dạng khuôn mặt / TensorFlow, thuật toán biến hình Beier–Neely và hiệu ứng thị sai. Đừng lo lắng, sẽ có rất nhiều hình ảnh và video. Bài đăng này không phải là một hướng dẫn chi tiết mà là một bài giới thiệu một vài khái niệm, tuy nhiên nó sẽ đủ tốt để làm tài liệu học tập cùng với kho lưu trữ Github.
Giới thiệu về đám mây điểm và Three.js
Nói chung, một đám mây điểm chỉ là một tập hợp các điểm trong không gian. Những đám mây điểm như vậy có thể thu được thông qua các nắp tức là hoặc thậm chí qua iPhone hiện đại. Các lidar hàng đầu có thể tạo ra vài triệu điểm mỗi giây nên việc tối ưu hóa và sử dụng GPU về cơ bản là điều bắt buộc để có được hình ảnh phù hợp. Tôi có thể viết một bài khác về các phương pháp trực quan hóa liên quan đến kích thước tập dữ liệu nhưng hiện tại Three.js và WebGL đã đủ tốt. Hy vọng rằng chúng ta sẽ sớm có thể sử dụng WebGPU — nó đã có sẵn nhưng cho đến nay vẫn chưa có gì chính thức về Three.js.
Three.js cung cấp cho chúng ta đối tượng Points 3D được hiển thị bên trong với cờ gl.POINTS . Lớp điểm mong đợi hai tham số, Vật liệu và Hình học. Chúng ta hãy có một cái nhìn sâu hơn về cả hai.
hình học
Có rất nhiều hình học trợ giúp như Plane of Sphere cung cấp cho bạn khả năng thực hiện các nguyên mẫu nhanh hơn, tuy nhiên tôi quyết định sử dụng BufferGeometry . Mặc dù bạn mất nhiều thời gian hơn để tạo ra thứ gì đó có ý nghĩa, nhưng bạn có toàn quyền kiểm soát mọi thứ liên quan đến hình học. Hãy cùng xem đoạn trích dưới đây:
if (this.guiWrapper.textureOptions.textureUsage) {
geometry.setAttribute('uv', new THREE.BufferAttribute(pointUv, this.UV_NUM))
} else {
geometry.setAttribute('color', new THREE.BufferAttribute(pointsColors, this.COL_NUM))
}
geometry.setAttribute('position', new THREE.BufferAttribute(pointsToRender, this.POS_NUM))
geometry.morphAttributes.position = []
geometry.morphAttributes.color = []
// Add flat mapping
geometry.morphAttributes.position[0] = new THREE.BufferAttribute(pointsToRenderFlat, this.POS_NUM)
geometry.morphAttributes.color[0] = new THREE.BufferAttribute(pointsColors, this.COL_NUM)
// Add "natural" mapping
geometry.morphAttributes.position[1] = new THREE.BufferAttribute(pointsToRenderMapped, this.POS_NUM)
geometry.morphAttributes.color[1] = new THREE.BufferAttribute(pointsColors, this.COL_NUM)
Trong các trường hợp đơn giản như hình ảnh 2D, bạn có thể sử dụng bản đồ độ sâu làm bản đồ dịch chuyển nhưng trong trường hợp sử dụng của tôi, tôi cần hành vi phức tạp hơn của các đám mây điểm và mô hình 3D.
Vật chất
Một lần nữa, có rất nhiều tài liệu được xác định trước trong Three.js nhưng trong trường hợp của chúng tôi, tôi đã sử dụng ShaderMaterial vì chúng tôi có thể cung cấp các trình tạo bóng tùy chỉnh và có tính linh hoạt hơn/hiệu suất tốt hơn. Đoạn mã tiếp theo hiển thị cả trình đổ bóng đỉnh và đoạn cơ bản. Một số tính năng bao gồm dành riêng cho Three.js để bạn có thể tận dụng API có thể truy cập được ở phía TypeScript. Vì mã của họ có sẵn trên Github nên bạn luôn có thể kiểm tra bất cứ thứ gì cần thiết.
export const VERTEX_SHADER = `
#ifdef GL_ES
precision highp float;
#endif
uniform float size;
uniform float scale;
#define USE_COLOR
#define USE_MORPHCOLORS
#include <common>
#include <color_pars_vertex>
#include <morphtarget_pars_vertex>
attribute vec3 color;
varying vec2 vUv;
uniform float u_time;
uniform float u_minZ;
uniform float u_maxZ;
uniform float u_scale;
uniform vec3 u_camera_angle;
uniform int u_parallax_type;
void main()
{
#include <color_vertex>
#include <begin_vertex>
#include <morphtarget_vertex>
vColor = color;
vUv = uv;
gl_PointSize = 2.0;
#if defined( MORPHTARGETS_COUNT )
vColor = morphTargetBaseInfluence;
for ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {
if ( morphTargetInfluences[ i ] != 0.0 ) vColor += getMorph( gl_VertexID, i, 2 ).rgb morphTargetInfluences[ i ];
}
#endif
if (u_parallax_type == 1) {
transformed.x += u_scale*u_camera_angle.x*transformed.z*0.05;
transformed.y += u_scale*u_camera_angle.y*transformed.z*0.02;
} else if (u_parallax_type == 2) {
transformed.x += transformed.z*cos(0.5*u_time)*0.01;
transformed.y += transformed.z*sin(0.5*u_time)*0.005;
}
vec4 mvPosition = modelViewMatrix * vec4( transformed, 1.0 );
gl_Position = projectionMatrix * mvPosition;
}
`;
export const FRAGMENT_SHADER = `
#ifdef GL_ES
precision highp float;
#endif
varying vec3 vColor;
varying vec2 vUv;
uniform sampler2D u_texture;
uniform bool u_use_texture;
void main()
{
if (u_use_texture) {
gl_FragColor = texture2D(u_texture, vUv);
} else {
gl_FragColor = vec4( vColor, 1.0 );
}
}
`;
Lưu ý quan trọng liên quan đến trang web demo trực tiếp. Gifs và video nhìn thấy trong bài đăng này đã được tạo trong khi trình hiển thị sử dụng nội dung thô / HQ. Bản trình diễn trực tiếp sử dụng nội dung được thu nhỏ theo hệ số xấp xỉ 10, tổng cộng khoảng 15 MB. Ngoài ra, trong khi trên env cục bộ, tôi có thể đủ khả năng tải dữ liệu thô và đợi vài giây, bản demo trực tiếp đang sử dụng một số phím tắt nội bộ để giúp trải nghiệm tốt hơn. Tức là cấu trúc hình học được giảm tải một phần cho Web worker hoặc tải nội dung dưới dạng kết cấu và tải chúng vào canvas ngoài màn hình để tôi có thể đọc ImageData và điền vào bộ đệm cơ bản và biến hình. - Lần tuyệt vọng gọi cho the biện pháp tuyệt vọng. :]
Hãy làm cho những đám mây nhảy múa!
Trong một số phần tiếp theo, tôi sẽ cho bạn biết thêm về các khái niệm được sử dụng và mô tả các tình huống demo mà bạn có thể thử sau. Tôi sẽ không mô tả chi tiết các quy trình như [Bộ ảnh -> Nhận dạng khuôn mặt -> Xuất dữ liệu -> Tích hợp Three.js] nhưng bạn có thể tự thực hiện bằng cách nhấp vào các liên kết được cung cấp.

MiDaS và hiệu ứng thị sai
MiDaS là một mô hình học máy tuyệt vời có thể tính toán bản đồ độ sâu từ một hình ảnh duy nhất (ước tính độ sâu bằng một mắt). Đã học trên nhiều bộ dữ liệu, nó mang lại kết quả thực sự tốt và bạn có thể thoải mái sử dụng nó trên thiết bị của mình. Hầu hết quá trình thiết lập được thực hiện thông qua conda, bạn chỉ cần tải xuống kiểu máy mong muốn (tối đa 1,3 GB). Thật là một thời gian để sống, bạn có thể dễ dàng tận dụng kiến thức của một nhóm người thông minh ở nhà. Khi bạn có một hình ảnh và bản đồ độ sâu tương ứng của nó, không có gì ngăn cản bạn sử dụng trình kết xuất đám mây điểm đó như bạn có thể thấy trong video tiếp theo.
Nhưng chờ đợi, có nhiều hơn nữa! Các mô hình Text2Image, Image2Image, Text2Mesh, v.v. đang là xu hướng và chúng tôi không thể tụt lại phía sau. Video tiếp theo hiển thị một số hình ảnh do AI tạo ra (Midjourney và Stable Diffusion) được xử lý thông qua MiDaS và được hiển thị trực quan (Khi hình ảnh đang thay đổi, chúng tôi chỉ hoán đổi bộ đệm biến hình đã đề cập trước đó):
Bạn có thể nhận ra rằng hình ảnh đang chuyển động. Đó là công việc của vertex shader của chúng ta. Có hai loại hiệu ứng thị sai. Đầu tiên là theo dõi vị trí của máy ảnh và đang cố gắng biến đổi đám mây để trung tâm của hình ảnh nhìn thẳng vào máy ảnh. Hiệu ứng này hoạt động khá tốt nhưng nó phải phức tạp hơn vì trọng lực không phải lúc nào cũng được định vị ở trung tâm. Tôi đang lên kế hoạch viết cách tiếp cận tốt hơn sớm. Đoạn mã dưới đây cho thấy việc chuyển các tham số thị sai sang đồng phục để mọi đỉnh có thể điều chỉnh cho phù hợp. Cuối cùng, điều này cũng sẽ được chuyển sang shader.
//Will be simplified once testing / prototyping is finished
const imgCenterPoint = this.geometryHelper.imgCenterPoints[this.guiWrapper.renderingType.getValue()][this.interpolationOptions.frame]
this.lastCameraPosition = this.camera.position.clone()
let angleX = this.camera.position.clone().setY(0).angleTo(imgCenterPoint)
let angleY = this.camera.position.clone().setX(0).angleTo(imgCenterPoint)
let normalX = new THREE.Plane().setFromCoplanarPoints(new THREE.Vector3(), this.camera.position.clone().setY(0), imgCenterPoint).normal
let normalY = new THREE.Plane().setFromCoplanarPoints(new THREE.Vector3(), this.camera.position.clone().setX(0), imgCenterPoint).normal
this.parallaxUniforms.u_scale.value = 1 + this.currentSceneObject.scale.z
this.parallaxUniforms.u_camera_angle.value = new THREE.Vector3(-angleX*normalX.y, angleY*normalY.x, 0)
Biến hình ảnh và Koalas
Bản đồ độ sâu và hình ảnh do AI tạo ra đều tuyệt vời nhưng chúng ta cũng cần tôn trọng tự nhiên. Có một kịch bản tên là Koala với hai tính năng. Biến đổi hình ảnh giữa hai hình ảnh và biểu diễn hình ảnh “tự nhiên”. Hãy bắt đầu với cái sau. Trong trường hợp này, màu sắc được ánh xạ vào mặt phẳng z dưới dạng số 24 bit và sau đó được chuẩn hóa. Có điều gì đó kỳ diệu về biểu diễn hình ảnh không gian, đặc biệt là vì nó cho chúng ta khả năng cảm nhận các đối tượng và nó cũng mang lại trực giác gần hơn về lý do tại sao các mạng thần kinh nhận dạng đối tượng hoạt động. Bạn gần như có thể cảm nhận được đặc tính của các vật thể trong không gian:
Về phần biến hình, trong kịch bản này, chúng ta có hai chú gấu túi khác nhau và 10 hình ảnh trung gian biến hình — bạn có thể duyệt qua chúng qua thanh trượt hoặc để WebGL tạo hoạt ảnh cho chúng một cách linh hoạt. Hình ảnh giữa các bản gốc được tạo thông qua thuật toán biến hình Beier–Neely . Đối với thuật toán này, bạn cần ánh xạ 1–1 tập hợp các đường trong đó mỗi đường biểu thị một số đặc điểm như mắt hoặc cằm. Tôi sẽ nói về thuật toán chi tiết hơn trong phần tiếp theo. Trong video bên dưới, bạn có thể thấy có một số đoạn kỳ lạ vì tôi chỉ sử dụng 10 tính năng dòng và muốn xem kết quả. Nhưng kể cả khi không hoàn hảo thì bản hoạt hình cũng khá đẹp, đặc biệt là chiếc khăn biến hình, rất ảo giác:
Image Morphing, Portrait Depth API và waifus
Đã đến lúc kết hợp mọi thứ ở trên và xem xét ví dụ phức tạp hơn. Trong phần này, chúng ta sẽ nội suy giữa hai hình ảnh với bản đồ độ sâu. Như tôi đã đề cập, MiDaS nói chung là tuyệt vời, nhưng khi bạn muốn có được bản đồ độ sâu của khuôn mặt người, bạn cần một mô hình chi tiết hơn, được đào tạo đặc biệt trên khuôn mặt người (điều đó không có nghĩa là bạn không thể có được bản đồ độ sâu khá tốt từ MiDaS). Đầu năm nay, TensorFlow đã trình bày Portrait Depth API và tương tự như MiDaS, dự án này cũng có thể chạy trên thiết bị của bạn. Đối với phần này, tôi chọn Anne Hathaway và Olivia Munn làm mục tiêu biến hình. Trong danh sách thả xuống, bạn có thể tìm thấy phần này có tên là Waifus. Đừng hỏi tôi tại sao. Trước khi biến hình, chúng ta hãy xem bản đồ độ sâu của Anne dưới dạng đám mây điểm:
Chúng ta có thể thấy rằng mô hình đã cho chúng ta kết quả khá tốt. Chúng tôi sẽ không thể tạo ra một lưới giống như thật của khuôn mặt người nhưng nếu không thì sẽ có những chi tiết đẹp như răng có thể tích, đầu rõ ràng ở mức độ khác với thân và đường nét trên khuôn mặt cũng chính xác. Người ta có thể nói rằng có một sự cố hình ảnh vì có vẻ như Anne có chiếc cằm thứ hai nhưng chúng tôi biết điều đó là không đúng. Như Nietzsche đã nói “Chỉ những người có hai cằm mới là những người đang nhìn xuống vực thẳm của cuộc đời thất bại của họ.” Ngoài ra, đừng bao giờ tin vào bất kỳ trích dẫn nào trên internet. :]
Portrait Depth API hoạt động theo hai bước. Lúc đầu, nó phát hiện khuôn mặt/tóc/cổ và các bộ phận gần mặt khác (tôi không chắc nó có trọng số như thế nào, câu này chỉ xuất phát từ kinh nghiệm của tôi khi loay hoay với người mẫu), sau đó nó tạo một mặt nạ đen trên khuôn mặt. phần còn lại của hình ảnh và cuối cùng tạo ra một bản đồ độ sâu. Bước tạo mặt nạ không phải lúc nào cũng chính xác nên bản đồ độ sâu cuối cùng có nhiễu sắc nét ở rìa. May mắn thay, đó là một tiếng ồn tốt — nó có thể được loại bỏ trong cùng một hướng dẫn trong khi ghi vào bộ đệm. Tôi đã viết heuristic để loại bỏ tiếng ồn và nó hoạt động ngay lần thử đầu tiên.
removeNoise(depthMapColor: number, depthmap: Array<Array<number>>, i: number, j: number, width: number, height: number, pointPointer: number, pointsToRender: Float32Array) {
if (depthMapColor != 0) {
const percentage = depthMapColor/100
let left = depthMapColor
let right = depthMapColor
let top = depthMapColor
let down = depthMapColor
const dropThreshold = 5*percentage
if (j > 0) left = depthmap[i][j-1]
if (j < width-1) right = depthmap[i][j+1]
if (i > 0) top = depthmap[i-1][j]
if (i < height-1) down = depthmap[i+1][j]
if(Math.abs(left - depthMapColor) > dropThreshold || Math.abs(right - depthMapColor) > dropThreshold) {
pointsToRender[pointPointer*3 + 2] = 0
}
else if(Math.abs(top - depthMapColor) > dropThreshold || Math.abs(down - depthMapColor) > dropThreshold) {
pointsToRender[pointPointer*3 + 2] = 0
} else {
// extra scale
pointsToRender[pointPointer*3 + 2] = 3*(1 - depthMapColor)
}
}
}


Vì quá trình biến hình của gấu túi tạo ra các mảnh vỡ nên để biến hình khuôn mặt, tôi quyết định sử dụng TensorFlow và mô hình nhận dạng khuôn mặt/đặc điểm của chúng. Tôi đã sử dụng khoảng 80 dòng tính năng, nhiều hơn 8 lần so với gấu túi:
Kết quả tốt hơn nhiều và nó trông rất tuyệt, ngay cả khi sử dụng bản đồ độ sâu! Có một trục trặc với mái tóc của Olivia nhưng không có gì là không thể sửa chữa sau này. Khi bạn tạo hiệu ứng biến hình này, mỗi khung hình sẽ cắt đi một đoạn pixel. Điều này là do nền được coi là phẳng nhưng cũng có thể được sửa trong quá trình xử lý hậu kỳ, chỉ cần ánh xạ đoạn đó thành chức năng mượt mà và tăng tốc hoạt ảnh liên quan đến khoảng cách từ đầu.
Hình ảnh Morphing nói chung
Về thuật toán biến hình Beier–Neely, tôi không thể tìm thấy bất kỳ triển khai song song hoặc tăng tốc GPU nào nên việc tính toán các khung trung gian trên các hình ảnh lớn như 2k*2k pixel với hàng chục dòng đặc trưng mất rất nhiều thời gian. Trong tương lai, tôi đang lên kế hoạch viết những triển khai này. Lần triển khai đầu tiên sẽ dành cho việc sử dụng cục bộ/máy chủ thông qua CUDA và lần triển khai thứ hai sẽ sử dụng GPGPU và trình đổ bóng. GPGPU đặc biệt thú vị vì bạn có thể sáng tạo hơn.
Có một số dự án (tức là DiffMorph ) đi kèm với các mạng thần kinh trong quá trình biến hình — như một quá trình tăng tốc hoặc như một quá trình hoàn chỉnh. Lý do đằng sau việc sử dụng và thử nghiệm thuật toán xác định cũ tốt là tôi muốn xem xét một số bài báo và thực hiện triển khai liên quan đến GPU.
Dự án phụ rất tuyệt
Nó bắt đầu như một nguyên mẫu nhanh chỉ để trực quan hóa các đám mây điểm từ dự án khác của tôi nhưng cuối cùng tôi đã dành khoảng một tuần để thử nghiệm và thêm các tính năng mới chỉ để giải trí và tôi cũng đã học được một số điều trong quá trình thực hiện. Bên dưới, bạn có thể tìm thấy liên kết tới github của tôi với mã hoàn chỉnh và tới trang web để trình diễn trực tiếp. Hãy thoải mái rẽ nhánh hoặc thậm chí tạo PR với những ý tưởng mới. Tôi dự định thêm nhiều tính năng hơn nhưng tôi không muốn làm hỏng nhiều hơn nữa. Chỉ là tuyên bố từ chối trách nhiệm, tôi không phải là nhà phát triển web nên có thể tôi đang sử dụng một số cấu trúc kỳ lạ nhưng tôi sẵn sàng đón nhận những lời chỉ trích!
Kho lưu trữ Github
Bản trình diễn web trực tiếp
Dự án là một trang web tĩnh không có khả năng tải lên hình ảnh tùy chỉnh. Để sử dụng tùy chỉnh, bạn cần sao chép kho lưu trữ và tự mình cung cấp hình ảnh/bản đồ độ sâu. Nếu bạn đã từng sử dụng conda và npm, bạn sẽ không gặp vấn đề gì liên quan đến MiDaS và các công cụ khác. Tôi có thể làm cho dự án trở nên động và tạo bản đồ độ sâu/biến hình/ảnh 3D ở phía máy chủ nhưng điều đó phụ thuộc vào khả năng thời gian của tôi. Nhưng tôi chắc chắn đang có kế hoạch thêm các tính năng và tái cấu trúc. Một số tình huống khiến trang tải lại trên iPhone khi sử dụng Safari hoặc Chrome, Sẽ sớm gỡ lỗi đó.