[g-webgl] 支持裁剪区域
- Dominant language
- TypeScript
- Stars
- 1.2k
- Forks
- 230
- PR merge metrics
- No merged PRs in 30d
Description
http://g-next.antv.vision/zh/docs/api/basic/display-object#clippath
参考 Oasis 团队的实现,使用 Stencil,仅支持内遮照:https://zhuanlan.zhihu.com/p/394716496
G 之前支持通过 `setClip` 为一个图形设置裁切区域(区域内的部分显示,区域外的隐藏),例如画一张圆形图片。可该属性值可以是任意基础图形,例如 Circle、Rect 等等。

# API
在新版中我们参考 CSS 的 [clip-path](https://developer.mozilla.org/zh-CN/docs/Web/CSS/clip-path)。例如我们想创建一个圆形的图片:
```javascript
const image = new Image({
attrs: {
x: 100,
y: 100,
width: 200,
height: 200,
clipPath: new Circle({ // 裁剪区域
attrs: {
x: 100, // 相对于 Image 的局部坐标系
y: 100,
r: 50,
},
}),
}
});
```
也可以在创建图形之后设置裁剪区域,因此以上写法等价于:
```javascript
const image = new Image({
attrs: {
//... 省略其他属性
}
});
image.style.clipPath = new Circle({
attrs: {
r: 10,
},
});
// 或者兼容旧版写法
image.setClip(new Circle({
attrs: {
r: 10,
},
}));
```
## 注意事项
### 裁剪区域图形
理论上应当支持 Group 甚至是自定义图形,但看了下旧版 G 也不支持,暂时保持一致。
### 裁剪图形坐标系
相对于原始图形,因此在上面的例子中,在 200 * 200 的图片中挖一个“圆洞”,需要将 Circle 圆心设置为 `100, 100`。
### 拾取 & 包围盒
计算方式:原图形与裁剪区域的交集
### 修改裁剪图形
表示裁剪区域的图形虽然不在场景图中,但它的修改同样会触发重绘,例如修改 Circle 的半径,对裁剪区域进行变换等。此时都需要设置被裁剪图形的脏标志。
但大部分绘图属性对于裁剪图形都是无意义的,例如 fill 并不会影响裁剪区域。
# Canvas 实现
Canvas 的实现比较简单,在已有渲染流程中插入 clip。其实目前的脏矩阵渲染使用的就是 clearRect + clip:
```tsx
// 获取裁剪图形
const clipPathShape = object.style.clipPath;
if (clipPathShape) {
context.save();
// apply clip shape's RTS
this.applyTransform(context, clipPathShape.getLocalTransform());
// generate path in local space
const generatePath = this.pathGeneratorFactory(clipPathShape.nodeName);
if (generatePath) {
this.useAnchor(context, clipPathShape, () => {
// 绘制裁剪图形路径
context.beginPath();
generatePath(context, clipPathShape.parsedStyle);
context.closePath();
});
}
context.restore();
// 只有该区域内允许绘制,超出部分被裁剪
context.clip();
}
// 正常绘制被裁剪图形
```
# SVG 实现
声明 [clipPath](https://developer.mozilla.org/zh-CN/docs/Web/SVG/Element/clipPath) 后,通过 SVG 元素的 [clip-path](https://developer.mozilla.org/zh-CN/docs/Web/SVG/Attribute/clip-path) 属性引用:
```html
```
需要注意按照目前的规范 clipPath 内不能包含 `` ,但这一点的合理性仍在讨论中:
- [https://stackoverflow.com/questions/66417211/why-does-g-not-work-in-clippath-in-svg](https://stackoverflow.com/questions/66417211/why-does-g-not-work-in-clippath-in-svg)
- [https://github.com/w3c/fxtf-drafts/issues/17](https://github.com/w3c/fxtf-drafts/issues/17)
- [https://github.com/w3c/svgwg/issues/720](https://github.com/w3c/svgwg/issues/720)
# WebGL 实现
Oasis 团队写了一篇很好的文章:[https://zhuanlan.zhihu.com/p/394716496](https://zhuanlan.zhihu.com/p/394716496),下图来自文章内,G 中的裁剪图形其实是“内遮罩”效果:

其他教程可参考:
- [https://webgl2fundamentals.org/webgl/lessons/webgl-qna-how-to-use-the-stencil-buffer.html](https://webgl2fundamentals.org/webgl/lessons/webgl-qna-how-to-use-the-stencil-buffer.html)
- [https://open.gl/depthstencils](https://open.gl/depthstencils)
- [http://www.jiazhengblog.com/blog/2016/04/05/2941/](http://www.jiazhengblog.com/blog/2016/04/05/2941/)
基本思路是使用 stencil buffer:
> 可以在Buffer中指定一个形状作为模板,接着通过stencil test(模板测试)过程让位于形状内部的物体显示,而外部不显示,类似遮罩的效果。当然也可以反过来,让形状内部不显示物体,而外部显示。Stencil buffer为每个fragment提供8位的存储空间,即可以存储256个不同的数值,但是如果要实现一个简单的模板剪裁效果,其实1位(0和1)就够用了。Stencil buffer的作用如下图所示:

## 开启 stencil
在创建 webgl 上下文时,需要开启`stencil`(这一步挺容易遗漏的),在 three.js 中是默认开启的:
```tsx
const options: WebGLContextAttributes = {
// @see https://webglfundamentals.org/webgl/lessons/webgl-qna-how-to-use-the-stencil-buffer.html
stencil: true,
antialias: false,
// @see https://stackoverflow.com/questions/27746091/preservedrawingbuffer-false-is-it-worth-the-effort
preserveDrawingBuffer: false,
};
// 获取 webgl 上下文
gl = $canvas.getContext('webgl2', options);
```
## 开启扩展
WebGL1 需要开启深度纹理扩展(WebGL2 内置):[https://developer.mozilla.org/en-US/docs/Web/API/WEBGL_depth_texture](https://developer.mozilla.org/en-US/docs/Web/API/WEBGL_depth_texture)
开启后在创建纹理、RT 时支持新的类型(见下一小节)。
## 创建 RenderTarget
关联 DepthStencil Slot,存储深度与模版缓冲数据:
```tsx
const mainDepthDesc = makeBackbufferDescSimple(
RGAttachmentSlot.DepthStencil,
renderInput,
opaqueWhiteFullClearRenderPassDescriptor,
);
```
```tsx
// 创建 rt
const gl_format = this.device.translateTextureInternalFormat(pixelFormat);
case Format.D24_S8:
return GL.DEPTH24_STENCIL8;
// 创建纹理
const gl_format = this.device.translateTextureFormat(this.pixelFormat);
const gl_type = this.device.translateTextureType(this.pixelFormat);
// format
case Format.D24_S8:
case Format.D32F_S8:
return GL.DEPTH_STENCIL;
// type
case FormatTypeFlags.D24S8:
// @see https://developer.mozilla.org/en-US/docs/Web/API/WEBGL_depth_texture
return GL.UNSIGNED_INT_24_8;
```
从下图中可以看出 Framebuffer 关联的 attachment:1个 depth,1个 stencil,2个 color:

## 清空模版
在开始绘制模版前需要先清空,WebGL1 / 2 可以使用不同方式:
```tsx
if (isWebGL2(gl)) {
gl.clearBufferiv(gl.STENCIL, 0, [stencilClearValue]);
} else {
gl.clearStencil(stencilClearValue);
gl.clear(gl.STENCIL_BUFFER_BIT);
}
```
## 绘制模版
当一个图形作为模版绘制时,需要设置不同的材质属性([G 的材质系统](https://yuque.antfin-inc.com/antv/czqvg5/co0gz2)):
```tsx
if (this.isClipPath) {
// 作为模版被绘制
this.material.stencilWrite = true;
this.material.depthWrite = false;
this.material.stencilCompare = CompareMode.Always;
this.material.stencilPassOp = StencilOp.Replace;
this.material.stencilRef = 1;
} else {
// 作为正常图形绘制
}
```
其中`stencilWrite`表示需要写入模版缓冲,而`depthWrite`表示不需要写入深度缓冲。在绘制模版时,`Replace`表示直接写入模版缓冲,写入值使用`stencilRef`这里设置为 1,此时模版缓冲内容如下:

往 stencil buffer 中写时,我们不希望影响到 color buffer,通过 colorMask 可以关闭写入。等模版绘制完成后再打开:
```tsx
renderInst.setMegaStateFlags({
attachmentsState: [
{
channelWriteMask: this.material.stencilWrite
? ChannelWriteMask.None // 会导致 gl.colorMask(false, false, false, false)
: ChannelWriteMask.AllChannels,
}
]
});
```
## 绘制被裁剪图形
在正常绘制图形时,通过与模版缓冲中写入的值比较,决定是否写入 color buffer。如果从渲染管线的角度可以看到模版测试所处的位置,测试通过才输出。下图来自:[http://www.jiazhengblog.com/blog/2016/04/05/2941/](http://www.jiazhengblog.com/blog/2016/04/05/2941/)

关闭模版缓冲写入,正常开启深度测试。和之前的 ref 值(设置为 1)比较即可,相等就输出:
```tsx
this.material.stencilWrite = false;
this.material.depthWrite = true;
this.material.stencilCompare = CompareMode.Equal;
this.material.stencilPassOp = StencilOp.Keep;
this.material.stencilRef = 1; // 与写入模版缓冲时设置的值配套
```
不难发现,如果模版缓冲中只有一个值,当绘制多组裁剪图形时,会出现错误的重叠:

## 多组 ref
解决办法当然是使用多组 ref。由于我们使用 D24_S8 格式(24 bit 存深度)纹理的,因此 stencil 能用 8 bit,取值范围 `0~255`
> A GLint specifying the reference value for the stencil test. This value is clamped to the range **0 to 2^n - 1** where n is the number of bitplanes in the stencil buffer. The default value is 0.
## 其他优化
在 Oasis 团队的文章中提到遮罩之间的 diff:[https://zhuanlan.zhihu.com/p/394716496](https://zhuanlan.zhihu.com/p/394716496)
Contributor guide
Assessment
This issue has not been assessed yet.