从 JavaScript 到 WGSL:用渐变渲染理解 GPU 编程思维

本文通过一个真实的渐变渲染案例,帮助习惯 JavaScript/TypeScript 的 CPU 侧程序员快速建立 GPU Shader 编程的心智模型。

引言:为什么要学 Shader?

作为前端或后端开发者,我们习惯了"顺序执行"的编程思维------代码从上到下一行行执行,循环遍历数组,逐个处理数据。但当你需要渲染成千上万个像素时,这种方式太慢了。

GPU 的核心优势是并行:它可以同时处理数千个像素,每个像素独立运行相同的代码(Shader)。理解这一点,是从 CPU 编程迁移到 GPU 编程的关键。


1. 思维转换:从"循环"到"并行"

JavaScript 思维(CPU)

假设你要给一个 200×200 的矩形填充渐变色,在 JS 中你可能会这样写:

ini 复制代码
// CPU 思维:顺序遍历每个像素
for (let y = 0; y < 200; y++) {
    for (let x = 0; x < 200; x++) {
        const t = x / 200;  // 计算渐变位置 [0, 1]
        const color = interpolateColor(startColor, endColor, t);
        setPixel(x, y, color);
    }
}

这段代码需要执行 40,000 次 循环,每次调用 setPixel

WGSL 思维(GPU)

在 Shader 中,你不需要写循环。GPU 会自动为每个像素启动一个独立的"线程",每个线程只负责计算自己那一个像素的颜色:

less 复制代码
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
    // 我是谁?GPU 告诉我我正在处理的像素坐标
    let localPos = input.localPos;  // 比如 (100, 50)
    
    // 计算我这个像素的渐变位置
    let t = localPos.x / 200.0;
    
    // 返回我这个像素应该显示的颜色
    return mix(startColor, endColor, t);
}

💡 mix 函数详解

mix(a, b, t) 是 GPU 内置的线性插值函数 ,等价于 a * (1 - t) + b * t

JavaScript 等价实现

css 复制代码
function mix(a, b, t) {
    return a * (1 - t) + b * t;
}
// 当 t=0 时返回 a,t=1 时返回 b,t=0.5 时返回 a 和 b 的中间值

mix 的妙用:替代 if 语句

在 GPU 中,if 分支会导致性能问题(后文详述)。mix 可以优雅地替代某些条件判断:

scss 复制代码
// ❌ 有分支的写法
if (isHovered) { color = hoverColor; } else { color = normalColor; }

// ✅ 无分支的写法(isHovered 为 0.0 或 1.0)
color = mix(normalColor, hoverColor, f32(isHovered));

核心区别

对比项 JavaScript (CPU) WGSL (GPU)
执行模式 一个线程,循环 40,000 次 40,000 个线程,每个执行 1 次
数据访问 可以访问任意像素 只知道"我自己"的坐标
返回值 调用 setPixel return 颜色值

💡 类比:想象你是工厂里的一个工人(GPU 线程),你只负责给传送带上经过你面前的那一个产品上色。你不知道也不关心其他工人在干什么,你只需要知道"我这个产品应该是什么颜色"。


2. Shader 的两个阶段:Vertex 和 Fragment

GPU 渲染管线主要分两步,对应两种 Shader:

2.1 Vertex Shader(顶点着色器)------ "形状在哪里?"

ini 复制代码
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
    var output: VertexOutput;
    
    // 1. 将顶点从模型空间变换到屏幕空间
    let pos = uniforms.transform * vec3<f32>(input.position, 1.0);
    output.position = vec4<f32>(pos.xy, 0.0, 1.0);
    
    // 2. 把原始坐标传给 Fragment Shader
    output.localPos = input.position;
    
    return output;
}

职责 :处理几何图形的顶点(三角形的三个角),决定它们在屏幕上的位置。

类比:如果把渲染比作"填色游戏",Vertex Shader 就是"画轮廓线"的步骤。

为什么需要空间变换?

你可能会问: "为什么不能直接用顶点坐标画图?" 答案是:可以,但只能画固定位置、固定大小的图形。

举个例子 :假设你定义了一个 100×100 的正方形,顶点坐标是 (0,0), (100,0), (100,100), (0,100)

场景 不用变换 用变换矩阵
移动到 (200, 150) 重新计算 4 个顶点坐标 只需修改矩阵的平移分量
放大 2 倍 重新计算 4 个顶点坐标 只需修改矩阵的缩放分量
旋转 45° 三角函数重算所有顶点 只需修改矩阵的旋转分量
同时移动+缩放+旋转 代码爆炸 💥 矩阵相乘,一行搞定

JavaScript 类比

arduino 复制代码
// ❌ 不用变换:每次都要重新算坐标
function drawSquare(x, y, size, rotation) {
    const cos = Math.cos(rotation), sin = Math.sin(rotation);
    const points = [
        [x + 0 * cos - 0 * sin, y + 0 * sin + 0 * cos],
        [x + size * cos - 0 * sin, y + size * sin + 0 * cos],
        // ... 太复杂了
    ];
}

// ✅ 用变换矩阵:顶点数据不变,只改矩阵
const vertices = [[0,0], [100,0], [100,100], [0,100]];  // 永远不变
const transform = mat3.multiply(translate, rotate, scale);  // 组合变换

核心优势

  1. 顶点数据可复用 ------ 同一个正方形的顶点数据可以被缓存,画 1000 个正方形只需要传 1000 个不同的矩阵
  2. 变换可组合 ------ 父子节点的变换通过矩阵乘法自动传递
  3. GPU 友好 ------ 矩阵乘法是 GPU 最擅长的运算

2.2 Fragment Shader(片元着色器)------ "像素是什么颜色?"

less 复制代码
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
    // 根据位置计算颜色
    if (uniforms.paintType == 0u) {
        return uniforms.color;  // 纯色填充
    }
    
    // 渐变填充...
    let gradPos = (uniforms.gradientTransform * vec3<f32>(input.localPos, 1.0)).xy;
    let t = gradPos.x;
    return interpolateGradient(t);
}

职责 :对三角形内部的每个像素计算颜色。这是 GPU 并行威力最大的地方。

类比:Fragment Shader 就是"填色"步骤,为轮廓线内的每个格子决定颜色。


3. 数据传递:CPU 如何与 GPU 通信?

在 JavaScript 中,函数之间通过参数和返回值通信。但 GPU 是一个独立的硬件,数据需要显式地"打包发送"。

3.1 Uniform:全局只读数据

less 复制代码
struct Uniforms {
    transform: mat3x3<f32>,      // 变换矩阵
    color: vec4<f32>,            // 颜色
    gradientTransform: mat3x3<f32>,
    paintType: u32,              // 0: 纯色, 1: 线性渐变, 2: 径向渐变
    stopCount: u32,
    stops: array<GradientStop, 8>,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;

特点

  • 所有线程共享同一份数据 (所有像素看到的 uniforms.color 是一样的)
  • 只读------Shader 不能修改它

JavaScript 对应概念:类似于"全局常量"或"配置对象"。

3.2 CPU 侧打包数据(TypeScript)

scss 复制代码
// 创建一个 ArrayBuffer,按照 GPU 要求的内存布局填充数据
const uniformData = new Float32Array(96);  // 384 bytes

// 填充变换矩阵 (mat3x3 在 GPU 中占 48 bytes)
uniformData.set(transformMatrix, 0);

// 填充颜色 (vec4 占 16 bytes)
uniformData.set([r, g, b, a], 12);

// 上传到 GPU
device.queue.writeBuffer(uniformBuffer, 0, uniformData);

⚠️ 大坑预警:内存对齐 (std140)

GPU 对数据布局有严格要求。vec3 不是 12 字节,而是 16 字节mat3x3 不是 36 字节,而是 48 字节

这是 CPU 程序员最容易踩的坑。详见后文"调试技巧"。


4. 渐变实现:完整案例解析

让我们用一个真实的渐变渲染来串联上述知识。

4.1 数据结构

rust 复制代码
struct GradientStop {
    color: vec4<f32>,    // RGBA 颜色
    position: f32,       // 位置 [0, 1]
    _pad0: f32,          // 填充对齐
    _pad1: f32,
    _pad2: f32,
}

为什么要 _pad 因为 GPU 要求结构体按 16 字节对齐。vec4 是 16 字节,f32 是 4 字节,总共 20 字节,需要填充到 32 字节。

为什么 GPU 要求 16 字节对齐?

这是硬件架构决定的,主要原因有三:

  1. 内存读取效率:GPU 的内存控制器按 16 字节(128 位)为单位读取数据。如果数据跨越两个 16 字节边界,需要两次内存访问,性能直接减半。
  2. SIMD 指令集:GPU 使用 SIMD(单指令多数据)架构,一条指令同时处理 4 个 float(正好 16 字节)。对齐的数据可以直接加载到寄存器,不对齐则需要额外的移位操作。
  3. 缓存行优化:GPU 缓存行通常是 128 字节或 256 字节。16 字节对齐确保数据不会横跨缓存行,避免缓存失效。

JavaScript 类比

arduino 复制代码
// 想象你有一个只能每次搬 4 瓶水的托盘(16 字节)
// ❌ 不对齐:3 瓶水放第一托盘,1 瓶放第二托盘 → 搬 4 瓶要跑两趟
// ✅ 对齐:4 瓶水放一个托盘,空位用泡沫填充 → 一趟搞定

std140 布局规则速记

类型 实际大小 对齐到 说明
f32 4 4
vec2 8 8
vec3 12 16 浪费 4 字节
vec4 16 16
struct 字段之和 最大字段对齐 × 整数倍 向上取整

4.2 坐标变换

从像素坐标到渐变参数 t 的转换是渐变的核心:

ini 复制代码
// 将像素坐标 (0~200) 变换到渐变空间 (0~1)
let gradPos = (uniforms.gradientTransform * vec3<f32>(input.localPos, 1.0)).xy;

// 线性渐变:水平方向的 x 坐标就是 t
let t = gradPos.x;

为什么需要渐变空间变换?

问题 :假设一个 200×100 的矩形,像素坐标范围是 x: 0~200, y: 0~100。如何计算每个像素的渐变位置 t

最简单的方法t = x / 200,即 x=0 时 t=0(起始色),x=200 时 t=1(结束色)。

但这只能实现从左到右的水平渐变。如果设计师想要:

  • 从上到下的垂直渐变?
  • 45° 斜向渐变?
  • 从中心向外的径向渐变?

答案就是空间变换矩阵。通过矩阵,我们可以把任意方向的渐变统一为"从左到右"的计算:

渐变类型 变换矩阵的作用 最终 t 的计算
水平(左→右) 归一化 x: x/width t = gradPos.x
垂直(上→下) 旋转 90° + 归一化 t = gradPos.x(原来的 y 变成了 x)
45° 斜向 旋转 45° + 归一化 t = gradPos.x
镜像渐变 缩放 x 为 2 倍 + 偏移 t = abs(gradPos.x) 或矩阵实现

JavaScript 类比理解

javascript 复制代码
// 不用矩阵:每种渐变写一套逻辑
function getGradientT_Horizontal(x, y, w, h) { return x / w; }
function getGradientT_Vertical(x, y, w, h) { return y / h; }
function getGradientT_Diagonal(x, y, w, h) { 
    // 45° 对角线...复杂的三角函数计算
}

// 用矩阵:统一为一套逻辑
function getGradientT(x, y, matrix) {
    const [gx, gy] = applyMatrix(matrix, [x, y]);
    return gx;  // 所有渐变都取变换后的 x
}

镜像渐变怎么做?

镜像渐变(如 红→蓝→红)可以通过两种方式实现:

方法 1:修改 Shader 逻辑

ini 复制代码
// 将 t 从 [0, 1] 映射为 [0, 1, 0](三角波)
let t_mirror = 1.0 - abs(gradPos.x * 2.0 - 1.0);

方法 2:通过矩阵实现(更灵活)

css 复制代码
// CPU 侧:构造一个"折叠"矩阵
// 将 [0, 0.5] 映射到 [0, 1],[0.5, 1] 映射到 [1, 0]

这样设计的优势

  1. Shader 代码简洁 ------ 无论什么方向的渐变,Shader 里永远是 t = gradPos.x
  2. 设计工具友好 ------ Figma 导出的 gradientTransform 可以直接使用
  3. 可组合 ------ 旋转、缩放、镜像可以通过矩阵乘法任意组合

4.3 颜色插值

ini 复制代码
// 线性渐变取 x,径向渐变取距离
var t: f32 = 0.0;
if (uniforms.paintType == 1u) {
    t = gradPos.x;                // 线性:只看水平位置
} else if (uniforms.paintType == 2u) {
    t = length(gradPos);          // 径向:计算到中心的距离
}

为什么线性渐变取 x,不取 y?

因为我们已经通过 gradientTransform 把任意方向的渐变都"旋转"成了水平方向

渐变方向 矩阵变换后的效果 t 的计算
从左到右 x: 0→1, y: 不变 t = x
从上到下 原来的 y 变成新的 x t = x(实际是原来的 y)
45° 对角 对角线方向变成新的 x t = x(实际是对角距离)

如果同时用 x 和 y 会怎样?

ini 复制代码
// 实验:不同的 t 计算方式
t = gradPos.x;                    // 水平渐变
t = gradPos.y;                    // 垂直渐变
t = (gradPos.x + gradPos.y) / 2.0; // 45° 对角渐变(简化版)
t = length(gradPos);              // 径向渐变(圆形)
t = max(abs(gradPos.x), abs(gradPos.y)); // "方形"径向渐变
t = gradPos.x * gradPos.y;        // 双曲线渐变(艺术效果)

JavaScript 可视化理解

ini 复制代码
// 想象一个 10×10 的网格,计算每个格子的 t 值
for (let y = 0; y < 10; y++) {
    let row = '';
    for (let x = 0; x < 10; x++) {
        const t_horizontal = x / 9;                    // 0, 0.11, 0.22, ... 1
        const t_radial = Math.sqrt(x*x + y*y) / 12.7;  // 圆形扩散
        row += t_horizontal.toFixed(1) + ' ';
    }
    console.log(row);
}
ini 复制代码
// 在 stops 数组中找到 t 所在的区间,进行线性插值
for (var i: u32 = 0u; i < 7u; i = i + 1u) {
    if (i >= lastIdx) { break; }
    
    let s0 = uniforms.stops[i];
    let s1 = uniforms.stops[i+1];
    
    if (t >= s0.position && t <= s1.position) {
        let factor = (t - s0.position) / (s1.position - s0.position);
        return mix(s0.color, s1.color, factor);  // GPU 内置的线性插值
    }
}

JavaScript 等价代码

ini 复制代码
function interpolate(t, stops) {
    for (let i = 0; i < stops.length - 1; i++) {
        if (t >= stops[i].position && t <= stops[i+1].position) {
            const factor = (t - stops[i].position) / (stops[i+1].position - stops[i].position);
            return lerpColor(stops[i].color, stops[i+1].color, factor);
        }
    }
}

GPU 中的控制语句

WGSL 支持常见的控制语句,但性能特性与 JavaScript 完全不同

JavaScript WGSL 说明
if (cond) { A } else { B } if (cond) { A } else { B } 语法相同,但性能代价大
for (let i = 0; i < n; i++) for (var i: u32 = 0u; i < n; i = i + 1u) 需要显式类型
while (cond) { } while (cond) { } 相同
switch (x) { case 1: ... } switch (x) { case 1: { ... } default: { } } 每个分支必须有 {}
break / continue break / continue 相同
return return 相同

⚠️ 为什么 if 在 GPU 中代价大?

GPU 的并行模型要求同一组线程(Warp/Wave)执行相同的指令。当遇到分支时:

scss 复制代码
if (condition) {
    A();  // 部分线程执行这里
} else {
    B();  // 部分线程执行这里
}

实际发生的是:所有线程都执行 A 和 B,但结果被掩码丢弃 。这叫做 Thread Divergence(线程分化)

性能优化替代方案

scss 复制代码
// ❌ 分支写法(两组线程各等待对方)
if (isHovered) { 
    color = hoverColor; 
} else { 
    color = normalColor; 
}

// ✅ 无分支写法(所有线程同时完成)
color = mix(normalColor, hoverColor, f32(isHovered));

// ✅ step 函数(阶跃函数,常用于边界判断)
// step(edge, x): x < edge 返回 0.0,否则返回 1.0
let mask = step(0.5, t);  // t < 0.5 时 mask=0,否则 mask=1
color = mix(colorA, colorB, mask);

// ✅ clamp + smoothstep(平滑过渡)
let t_clamped = clamp(t, 0.0, 1.0);  // 限制 t 在 [0, 1] 范围
let t_smooth = smoothstep(0.0, 1.0, t);  // 平滑的 S 曲线插值

何时可以用 if

  • 条件对所有像素相同(如 if (uniforms.paintType == 1u))------ 无分化,放心用
  • 分支内代码很短 ------ 分化代价可接受
  • 无法用数学替代的复杂逻辑 ------ 只能用 if

5. GPU 编程的"反直觉"特性

5.1 没有 console.log

在 Shader 中,你不能打印日志。这是最让 CPU 程序员抓狂的地方。

调试核心思路 :将关键变量的值分支执行情况 编码成可识别的颜色输出到屏幕上。

调试技巧:用颜色编码变量值

less 复制代码
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
    // ===== 技巧 1:输出坐标值 =====
    // 将坐标映射为颜色,检查坐标是否正确
    // return vec4<f32>(input.localPos.x / 200.0, input.localPos.y / 200.0, 0.0, 1.0);
    // 预期:左上角黑色,右下角黄色,形成渐变
    
    // ===== 技巧 2:检查分支执行 =====
    // 用不同颜色标记代码是否进入了某个分支
    // if (uniforms.paintType == 0u) { return vec4<f32>(1.0, 0.0, 0.0, 1.0); }  // 纯色 → 红
    // if (uniforms.paintType == 1u) { return vec4<f32>(0.0, 1.0, 0.0, 1.0); }  // 线性 → 绿
    // if (uniforms.paintType == 2u) { return vec4<f32>(0.0, 0.0, 1.0, 1.0); }  // 径向 → 蓝
    
    // ===== 技巧 3:检查变量范围 =====
    // 将 t 值输出为灰度,检查是否在 [0, 1] 范围内
    // let t = gradPos.x;
    // return vec4<f32>(t, t, t, 1.0);  // 预期:从黑到白的渐变
    // 如果全白/全黑 → t 超出范围,坐标变换有问题
    
    // ===== 技巧 4:二分法定位问题 =====
    // 在代码中间插入 return,逐步缩小问题范围
    // return vec4<f32>(1.0, 0.0, 0.0, 1.0);  // 红色检查点
    // ... 后续代码
    
    // 正常逻辑...
}
输出颜色 含义
纯红色 几何体正确绘制,或进入了"纯色"分支
纯绿色 进入了"线性渐变"分支
纯蓝色 进入了"径向渐变"分支
从黑到白渐变 t 值从 0 到 1 正常变化
全白 t 值始终 ≥ 1,坐标变换可能缩放错误
全黑 t 值始终 ≤ 0,坐标变换可能偏移错误
紫色(红+蓝) 进入了未知分支或错误路径

5.2 数据传输:理解"批量"与"Draw Call"

在 JavaScript 中,你可以随时修改变量:

ini 复制代码
color = 'red';
draw();
color = 'blue';
draw();

在 GPU 编程中,这涉及两个层面的理解:

层面 1:单次 Draw Call 内的数据共享

一次 draw() 调用会绘制一个(或一批)图形。在这次绘制中,所有像素共享同一份 Uniform 数据

如果你想画两个不同颜色的矩形,最简单的方式是:

scss 复制代码
// 方式 1:两次 Draw Call(伪代码)
setUniform({ color: 'red' });   // 内部调用 device.queue.writeBuffer()
draw(rect1Vertices);            // 第一次绘制

setUniform({ color: 'blue' });  // 再次调用 device.queue.writeBuffer()
draw(rect2Vertices);            // 第二次绘制

💡 setUniform 的本质

setUniform 不是 WebGPU 的原生 API,而是对以下操作的封装:

arduino 复制代码
function setUniform(data) {
    // 1. 将 JS 对象转换为二进制数据(按 std140 对齐)
    const buffer = packToFloat32Array(data);
    
    // 2. 通过 WebGPU API 将数据从 CPU 内存拷贝到 GPU 内存
    device.queue.writeBuffer(uniformBuffer, 0, buffer);
}

device.queue.writeBuffer() 是真正触发 CPU→GPU 数据传输的 API。每次调用都会产生一定的开销(内存拷贝 + 驱动调用),这就是为什么要尽量减少调用次数。

层面 2:批量绘制优化

多个图形当然可以批量发送! 这正是性能优化的关键。常见方案:

方案 A:Dynamic Uniform Buffer(动态偏移)

ini 复制代码
// 将所有图形的数据打包到一个大 Buffer
const bigBuffer = new Float32Array([
    ...rect1Transform, ...rect1Color,  // 偏移 0
    ...rect2Transform, ...rect2Color,  // 偏移 384
    ...rect3Transform, ...rect3Color,  // 偏移 768
]);
device.queue.writeBuffer(uniformBuffer, 0, bigBuffer);

// 一次性发送,通过偏移切换数据
for (let i = 0; i < 3; i++) {
    passEncoder.setBindGroup(0, bindGroup, [i * 384]);  // 动态偏移
    passEncoder.draw(...);
}

方案 B:实例化渲染(Instancing)------ 终极批量

javascript 复制代码
// 将变换矩阵放入 Storage Buffer
const transforms = new Float32Array([...所有图形的矩阵]);

// 一次 Draw Call 绘制 1000 个图形!
passEncoder.draw(vertexCount, instanceCount: 1000);
less 复制代码
// Shader 中通过 instance_index 获取自己的数据
@vertex
fn vs_main(@builtin(instance_index) instanceIdx: u32, ...) {
    let myTransform = transforms[instanceIdx];
}

性能对比

方式 1000 个矩形的 Draw Call 数 适用场景
朴素方式 1000 原型开发
Dynamic Uniform 1000(但切换更快) 不同形状、不同材质
Instancing 1 大量相同形状

5.3 矩阵是"列主序"

JavaScript 思维(行主序):

csharp 复制代码
const matrix = [
    [a, b, c],  // 第一行
    [d, e, f],  // 第二行
    [g, h, i],  // 第三行
];

GPU/WebGPU 思维(列主序):

arduino 复制代码
const buffer = new Float32Array([
    a, d, g,  // 第一列
    b, e, h,  // 第二列
    c, f, i,  // 第三列
]);

🔥 这是最常见的坑:如果你的图形位置完全错误或消失,80% 是矩阵存储顺序的问题。


6. 快速参考:类型对照表

JavaScript WGSL 大小(字节) 对齐要求
number f32 4 4
number (整数) u32 / i32 4 4
[x, y] vec2<f32> 8 8
[x, y, z] vec3<f32> 12 16 ⚠️
[r, g, b, a] vec4<f32> 16 16
3x3 矩阵 mat3x3<f32> 36 48 ⚠️
4x4 矩阵 mat4x4<f32> 64 64

7. 总结:心智模型迁移清单

CPU 思维 GPU 思维
循环遍历所有像素 每个像素独立运行相同代码
console.log 调试 用颜色输出变量值
随意访问全局变量 数据打包成 Buffer 发送
结构体大小 = 字段大小之和 必须考虑对齐(16 字节边界)
行主序矩阵 列主序矩阵
if/else 分支随意写 分支会降低性能(所有线程等待)

掌握这些核心差异,你就能从 JavaScript 程序员平滑过渡到 Shader 开发者。接下来,建议你动手修改 shader.wgsl 中的代码,用"颜色调试法"亲身体验 GPU 编程的独特魅力!

更多精彩内容可关注风起的博客,微信公众号:听风说图

相关推荐
明月_清风1 小时前
Async/Await:让异步像同步一样简单
前端·javascript
float_六七1 小时前
CSS行内盒子:30字掌握核心特性
前端·css
倔强的钧仔1 小时前
拒绝废话!前端开发中最常用的 10 个 ES6 特性(附极简代码)
前端·javascript·面试
喔烨鸭1 小时前
vue3中使用原生表格展示数据
前端·javascript·vue.js
软件开发技术深度爱好者2 小时前
JavaScript的p5.js库坐标系图解
开发语言·前端·javascript
donecoding2 小时前
CSS的"双胞胎"陷阱:那些看似对称却暗藏玄机的属性对
前端·css·代码规范
胖鱼罐头2 小时前
JavaScript 数据类型完全指南
前端·javascript
代码猎人2 小时前
map和Object有什么区别
前端
Snack2 小时前
border-radius带来的锯齿问题
前端