พลังของเว็บสำหรับนักวาดภาพประกอบ: วิธีที่ pixiv ใช้เทคโนโลยีเว็บสำหรับแอปวาดภาพของตน

pixiv เป็นบริการชุมชนออนไลน์สำหรับนักวาดภาพและผู้สนใจงานภาพเพื่อสื่อสารกันผ่านเนื้อหา ซึ่งช่วยให้ผู้ใช้โพสต์ภาพของตนเองได้ โดยมีผู้ใช้กว่า84 ล้านคนทั่วโลก และผลงานศิลปะที่โพสต์มากกว่า120 ล้านชิ้น ณ เดือนพฤษภาคม 2023

pixiv Sketch เป็นหนึ่งในบริการของ pixiv ซึ่งใช้วาดอาร์ตเวิร์กในเว็บไซต์โดยใช้นิ้วหรือปากกาสไตลัส โดยรองรับฟีเจอร์ต่างๆ มากมายสำหรับการวาดภาพอันน่าทึ่ง ซึ่งรวมถึงแปรง เลเยอร์ และการระบายสีด้วยโหมดถังหลายประเภท รวมถึงยังช่วยให้ผู้ใช้สามารถสตรีมสดขั้นตอนการวาดภาพได้ด้วย

ในกรณีศึกษานี้ เราจะดูวิธีที่ pixiv Sketch ปรับปรุงประสิทธิภาพและคุณภาพของเว็บแอปโดยใช้ฟีเจอร์ใหม่ๆ ของแพลตฟอร์มเว็บ เช่น WebGL, WebAssembly และ WebRTC

เหตุผลในการพัฒนาแอปวาดภาพบนเว็บ

pixiv Sketch เปิดตัวครั้งแรกบนเว็บและ iOS ในปี 2015 กลุ่มเป้าหมายของเวอร์ชันเว็บคือเดสก์ท็อปเป็นหลัก ซึ่งยังคงเป็นแพลตฟอร์มหลักที่ชุมชนนักวาดภาพใช้

เหตุผล 2 ข้อหลักที่ pixiv เลือกพัฒนาเวอร์ชันเว็บแทนแอปบนเดสก์ท็อปมีดังนี้

  • การสร้างแอปสำหรับ Windows, Mac, Linux และอื่นๆ มีค่าใช้จ่ายสูงมาก เว็บเข้าถึงเบราว์เซอร์ใดก็ได้บนเดสก์ท็อป
  • เว็บมีการเข้าถึงที่ดีที่สุดในแพลตฟอร์มต่างๆ เว็บพร้อมให้บริการบนเดสก์ท็อปและอุปกรณ์เคลื่อนที่ รวมถึงทุกระบบปฏิบัติการ

เทคโนโลยี

pixiv Sketch มีแปรงหลายแบบให้เลือก ก่อนที่จะใช้ WebGL เรามีแปรงเพียงประเภทเดียวเนื่องจากผืนผ้าใบ 2 มิติมีข้อจำกัดมากเกินไปที่จะแสดงพื้นผิวที่ซับซ้อนของแปรงแต่ละประเภท เช่น ขอบหยาบๆ ของดินสอ รวมถึงความกว้างและสีที่เข้ม/อ่อนแตกต่างกันไปซึ่งเปลี่ยนแปลงตามแรงกดในการวาด

แปรงประเภทต่างๆ ที่ใช้ WebGL

อย่างไรก็ตาม เมื่อใช้ WebGL ทีมพัฒนาได้เพิ่มรายละเอียดของแปรงให้หลากหลายขึ้นและเพิ่มจำนวนแปรงที่ใช้ได้เป็น 7 รายการ

แปรง 7 แบบใน pixiv ตั้งแต่แบบละเอียดไปจนถึงหยาบ คมชัดไปจนถึงไม่คมชัด พิกเซลไปจนถึงเรียบเนียน ฯลฯ

เมื่อใช้บริบทภาพพิมพ์แคนวาส 2 มิติ คุณจะวาดได้เฉพาะเส้นที่มีพื้นผิวเรียบง่ายที่มีความกว้างเท่าๆ กัน เช่นในภาพหน้าจอต่อไปนี้

จังหวะแปรงที่มีพื้นผิวเรียบง่าย

เส้นเหล่านี้วาดโดยการสร้างเส้นทางและวาดเส้น แต่ WebGL จะสร้างเส้นนี้ขึ้นมาใหม่โดยใช้จุดสไปรต์และโปรแกรมเปลี่ยนสี ซึ่งแสดงในตัวอย่างโค้ดต่อไปนี้

ตัวอย่างต่อไปนี้แสดงเวิร์กเท็กเจอร์

precision highp float;

attribute vec2 pos
;
attribute
float thicknessFactor;
attribute
float opacityFactor;

uniform
float pointSize;

varying
float varyingOpacityFactor;
varying
float hardness;

// Calculate hardness from actual point size
float calcHardness(float s) {
 
float h0 = .1 * (s - 1.);
 
float h1 = .01 * (s - 10.) + .6;
 
float h2 = .005 * (s - 30.) + .8;
 
float h3 = .001 * (s - 50.) + .9;
 
float h4 = .0002 * (s - 100.) + .95;
 
return min(h0, min(h1, min(h2, min(h3, h4))));
}

void main() {
 
float actualPointSize = pointSize * thicknessFactor;
  varyingOpacityFactor
= opacityFactor;
  hardness
= calcHardness(actualPointSize);
  gl_Position
= vec4(pos, 0., 1.);
  gl_PointSize
= actualPointSize;
}

ตัวอย่างต่อไปนี้แสดงโค้ดตัวอย่างสำหรับโปรแกรมเปลี่ยนรูปแบบเศษส่วน

precision highp float;

const float strength = .8;
const float exponent = 5.;

uniform vec4 color
;

varying
float hardness;
varying
float varyingOpacityFactor;

float fallOff(const float r) {
   
// w is for width
   
float w = 1. - hardness;
   
if (w < 0.01) {
     
return 1.;
   
} else {
     
return min(1., pow(1. - (r - hardness) / w, exponent));
   
}
}

void main() {
    vec2 texCoord
= (gl_PointCoord - .5) * 2.;
   
float r = length(texCoord);

   
if (r > 1.) {
     discard
;
   
}

   
float brushAlpha = fallOff(r) * varyingOpacityFactor * strength * color.a;

    gl_FragColor
= vec4(color.rgb, brushAlpha);
}

การใช้จุดสไปรท์ช่วยให้คุณปรับความหนาและการแรเงาได้ง่ายๆ เพื่อตอบสนองต่อแรงกดในการวาด ซึ่งช่วยให้คุณวาดเส้นที่เข้มและเส้นที่อ่อนได้ดังตัวอย่างต่อไปนี้

จังหวะแปรงที่คมชัดสม่ำเสมอและมีปลายบาง

เส้นแปรงที่ไม่คมชัดซึ่งกดแปรงแรงขึ้นตรงกลาง

นอกจากนี้ การใช้งานโดยใช้จุดสไปรต์สามารถแนบพื้นผิวได้โดยใช้เชดเดอร์แยกต่างหาก ซึ่งช่วยให้แสดงแปรงที่มีพื้นผิว เช่น ดินสอและปากกาสักหลาด ได้อย่างมีประสิทธิภาพ

การรองรับสไตลัสในเบราว์เซอร์

การใช้ปากกาสไตลัสดิจิทัลได้รับความนิยมอย่างมากในหมู่ศิลปินดิจิทัล เบราว์เซอร์สมัยใหม่รองรับ PointerEvent API ซึ่งช่วยให้ผู้ใช้ใช้ปากกาสไตลัสในอุปกรณ์ได้ โดยให้ใช้ PointerEvent.pressure เพื่อวัดแรงกดของปากกา และใช้ PointerEvent.tiltX, PointerEvent.tiltY เพื่อวัดมุมของปากกากับอุปกรณ์

หากต้องการใช้การลากเส้นด้วยสไปรท์จุด PointerEvent ต้องได้รับการประมาณและแปลงเป็นลำดับเหตุการณ์ที่ละเอียดยิ่งขึ้น ใน เหตุการณ์เคอร์เซอร์ คุณจะได้รับการวางแนวของสไตลัสในรูปแบบพิกัดขั้วโลก แต่ Pixiv Sketch จะแปลงพิกัดเหล่านั้นเป็นเวกเตอร์ที่แสดงการวางแนวของสไตลัสก่อนใช้งาน

function getTiltAsVector(event: PointerEvent): [number, number, number] {
 
const u = Math.tan((event.tiltX / 180) * Math.PI);
 
const v = Math.tan((event.tiltY / 180) * Math.PI);
 
const z = Math.sqrt(1 / (u * u + v * v + 1));
 
const x = z * u;
 
const y = z * v;
 
return [x, y, z];
}

function handlePointerDown(event: PointerEvent) {
 
const position = [event.clientX, event.clientY];
 
const pressure = event.pressure;
 
const tilt = getTiltAsVector(event);

  interpolateAndRender
(position, pressure, tilt);
}

เลเยอร์การวาดหลายเลเยอร์

เลเยอร์เป็นหนึ่งในแนวคิดที่โดดเด่นที่สุดในการวาดภาพดิจิทัล เลเยอร์ช่วยให้ผู้ใช้วาดภาพประกอบชิ้นต่างๆ ซ้อนทับกันได้ และแก้ไขทีละเลเยอร์ได้ โดย pixiv Sketch มีฟังก์ชันเลเยอร์เหมือนกับแอปวาดภาพดิจิทัลอื่นๆ

ตามปกติแล้ว คุณสามารถใช้เลเยอร์ได้โดยใช้<canvas> องค์ประกอบหลายรายการที่มี drawImage() และการดำเนินการคอมโพสิต แต่วิธีนี้ก่อให้เกิดปัญหาเนื่องจากบริบทแคนวาส 2 มิติไม่มีทางเลือกอื่นนอกจากต้องใช้โหมดการคอมโพส CanvasRenderingContext2D.globalCompositeOperation ซึ่งกําหนดไว้ล่วงหน้าและจํากัดความสามารถในการปรับขนาดได้อย่างมาก การใช้ WebGL และการเขียน Shader จะช่วยให้นักพัฒนาแอปใช้โหมดการคอมโพสิชันที่ API ไม่ได้กําหนดไว้ล่วงหน้าได้ ในอนาคต pixiv Sketch จะใช้ฟีเจอร์เลเยอร์โดยใช้ WebGL เพื่อให้ปรับขนาดและความยืดหยุ่นได้มากขึ้น

โค้ดตัวอย่างสำหรับองค์ประกอบเลเยอร์มีดังนี้

precision highp float;

uniform sampler2D baseTexture
;
uniform sampler2D blendTexture
;
uniform mediump
float opacity;

varying highp vec2 uv
;

// for normal mode
vec3 blend
(const vec4 baseColor, const vec4 blendColor) {
 
return blendColor.rgb;
}

// for multiply mode
vec3 blend
(const vec4 baseColor, const vec4 blendColor) {
 
return blendColor.rgb * blendColor.rgb;
}

void main()
{
  vec4 blendColor
= texture2D(blendTexture, uv);
  vec4 baseColor
= texture2D(baseTexture, uv);

  blendColor
.a *= opacity;

 
float a1 = baseColor.a * blendColor.a;
 
float a2 = baseColor.a * (1. - blendColor.a);
 
float a3 = (1. - baseColor.a) * blendColor.a;

 
float resultAlpha = a1 + a2 + a3;

 
const float epsilon = 0.001;

 
if (resultAlpha > epsilon) {
    vec3 noAlphaResult
= blend(baseColor, blendColor);
    vec3 resultColor
=
        noAlphaResult
* a1 + baseColor.rgb * a2 + blendColor.rgb * a3;
    gl_FragColor
= vec4(resultColor / resultAlpha, resultAlpha);
 
} else {
    gl_FragColor
= vec4(0);
 
}
}

การระบายสีพื้นที่ขนาดใหญ่ด้วยฟังก์ชันถังสี

แอป pixiv Sketch บน iOS และ Android มีฟีเจอร์ที่เก็บอยู่แล้ว แต่เวอร์ชันเว็บไม่มี ฟังก์ชันที่เก็บข้อมูลเวอร์ชันแอปติดตั้งใช้งานใน C++

เมื่อโค้ดฐานมีอยู่แล้วใน C++ ทาง pixiv Sketch จึงใช้ Emscripten และ asm.js เพื่อติดตั้งใช้งานฟังก์ชันที่เก็บข้อมูลลงในเวอร์ชันเว็บ

bfsQueue.push(startPoint);

while (!bfsQueue.empty()) {
 
Point point = bfsQueue.front();
  bfsQueue
.pop();
 
/* ... */
  bfsQueue
.push(anotherPoint);
}

การใช้ asm.js ช่วยให้เราสร้างโซลูชันที่มีประสิทธิภาพได้ เมื่อเปรียบเทียบเวลาในการดำเนินการของ JavaScript ล้วนๆ กับ asm.js เวลาในการดำเนินการโดยใช้ asm.js จะสั้นลง 67% ซึ่งคาดว่าจะดีขึ้นไปอีกเมื่อใช้ WASM

รายละเอียดการทดสอบ:

  • วิธี: ระบายสีพื้นที่ 1180x800 พิกเซลด้วยฟังก์ชันที่เก็บข้อมูล
  • อุปกรณ์ทดสอบ: MacBook Pro (M1 Max)

เวลาดำเนินการ:

  • JavaScript ล้วน: 213.8 มิลลิวินาที
  • asm.js: 70.3 มิลลิวินาที

เมื่อใช้ Emscripten และ asm.js ทาง pixiv Sketch ก็สามารถเผยแพร่ฟีเจอร์ที่เก็บข้อมูลได้สําเร็จด้วยการใช้โค้ดฐานซ้ำจากเวอร์ชันแอปเฉพาะแพลตฟอร์ม

สตรีมมิงแบบสดขณะวาดภาพ

pixiv Sketch มีฟีเจอร์สตรีมแบบสดขณะวาดผ่านเว็บแอป pixiv Sketch LIVE ซึ่งใช้ WebRTC API โดยรวมแทร็กเสียงจากไมโครโฟนที่ได้รับจาก getUserMedia() เข้ากับแทร็กวิดีโอ MediaStream ที่ได้มาจากองค์ประกอบ <canvas>

const canvasElement = document.querySelector('#DrawCanvas');
const framerate = 24;
const canvasStream = canvasElement.captureStream(framerate);
const videoStreamTrack = canvasStream.getVideoTracks()[0];

const audioStream = await navigator.mediaDevices.getUserMedia({
  video
: false,
  audio
: {},
});
const audioStreamTrack = audioStream.getAudioTracks()[0];

const stream = new MediaStream();
stream
.addTrack(audioStreamTrack.clone());
stream
.addTrack(videoStreamTrack.clone());

สรุป

คุณสามารถสร้างแอปที่ซับซ้อนบนแพลตฟอร์มเว็บและปรับขนาดแอปให้เหมาะกับอุปกรณ์ทุกประเภทได้ด้วย API ใหม่ๆ เช่น WebGL, WebAssembly และ WebRTC ดูข้อมูลเพิ่มเติมเกี่ยวกับเทคโนโลยีที่นําเสนอในกรณีศึกษานี้ได้ที่ลิงก์ต่อไปนี้