范围裁切和⼏何变换

233 阅读15分钟

Canvas 的范围裁切

一旦涉及到canvas的坐标变化的(旋转、缩放、平移)都需要逆向写代码,然后放大,在做偏移,最后画图

clipRect()
clipPath() // 切出来的圆为什么没有抗锯⻮效果?因为「强⾏切边」
clipOutRect() / clipOutPath()

clipRectclipPath 是用来裁剪绘制区域的方法,而 clipOutRectclipOutPath 是它们的反向操作。以下是这些方法的作用及相关的抗锯齿问题的解释:


1. clipRect()

  • 功能:裁剪一个矩形区域,只有这个矩形区域内的内容会被绘制,矩形区域外的内容会被裁剪掉。

  • 用法

    canvas.clipRect(left, top, right, bottom)
    
  • 常见用途:用来限制绘制区域,比如绘制一个部分视图。


2. clipPath()

  • 功能:裁剪一个由 Path 定义的区域,只有这个区域内的内容会被绘制,区域外的内容会被裁剪掉。

  • 用法

    val path = Path()
    path.addCircle(cx, cy, radius, Path.Direction.CW) // 添加圆形路径
    canvas.clipPath(path)
    
  • 抗锯齿问题

    • 使用 clipPath 时,裁剪的区域边缘是“硬切边”,因此会产生锯齿。

    • 原因clipPath 强制裁剪像素,未使用抗锯齿处理。

    • 解决方法

      • 开启抗锯齿:确保 Paint 对象的抗锯齿属性 isAntiAliastrue
      • 绘制边缘时,尽量避免复杂的路径操作,使用抗锯齿的形状填充代替裁剪。
      • 或者在裁剪前后,手动处理边缘区域的光滑效果。

3. clipOutRect()

  • 功能:从当前裁剪区域中裁剪掉指定的矩形区域,剩余区域继续绘制。

  • 用法

js canvas.clipOutRect(left, top, right, bottom) ```

  • 常见用途:用于反向裁剪,即排除指定矩形外的部分作为绘制区域。

4. clipOutPath()

  • 功能:从当前裁剪区域中裁剪掉指定路径(Path)所定义的区域,剩余部分继续绘制。

  • 用法

    val path = Path()
    path.addCircle(cx, cy, radius, Path.Direction.CW)
    canvas.clipOutPath(path)
    
  • 特点:这是 clipPath 的反向操作,裁剪掉 Path 定义的区域。


clipPath 的抗锯齿问题(切出来的圆没有抗锯齿效果)

  1. 本质原因clipPath 是“硬裁剪”操作,不支持抗锯齿的边缘处理,它直接基于像素点计算裁剪区域。

  2. 解决方法

    • 替代方法 1:使用遮罩绘制 在裁剪区域外设置一个半透明的遮罩,而不是直接使用 clipPath,这样可以让边缘更平滑:

      ```kotlin
      val paint = Paint(Paint.ANTI_ALIAS_FLAG)
      paint.color = Color.BLACK
      paint.alpha = 128 // 半透明
      val path = Path()
      path.addCircle(cx, cy, radius, Path.Direction.CW)
      canvas.drawPath(path, paint)
      ```
      
    • 替代方法 2:分步绘制 使用圆形填充而非裁剪,以减少边缘锯齿的显现:

      ```kotlin
      val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG)
      circlePaint.color = Color.WHITE
      canvas.drawCircle(cx, cy, radius, circlePaint)
      ```
      

总结

  • clipRectclipPath 用于限制绘制区域,clipOutRectclipOutPath 是它们的反向操作。
  • clipPath 的边缘没有抗锯齿效果,因为它是强制裁剪像素。可以通过替代绘制方法(如遮罩、填充路径等)来实现更平滑的边缘效果。

Canvas 的⼏何变换

重点:Canvas 的⼏何变换⽅法参照的是 View 的坐标系,⽽绘制⽅法(drawXxx())参照的是 Canvas ⾃⼰的坐标系。

translate(x, y)
rotate(degree)
scale(x, y)
skew(x, y)

1. translate(x, y)

  • 功能:移动 Canvas 的坐标系。

  • 作用

    • 将当前坐标系的原点移动到新的位置 (x, y)
    • 后续绘制操作都会基于新的原点进行。
  • 用法

    canvas.translate(50f, 100f)
    

    上述代码将坐标系的原点从 (0, 0) 移动到 (50, 100)

  • 特点

    • 不会改变绘制内容的形状或大小。
    • 常用于调整绘制内容的位置。

2. rotate(degree)

  • 功能:旋转 Canvas 的坐标系。

  • 作用

    • 绕当前坐标系的原点将坐标系顺时针旋转指定角度(以度为单位)。
    • 后续绘制操作会基于旋转后的坐标系进行。
  • 用法

    canvas.rotate(45f)
    

    上述代码会让坐标系顺时针旋转 45°。

  • 特点

    • 旋转的中心点是当前原点。

    • 如果需要绕特定点旋转,可以先用 translate 移动原点,再旋转,然后还原原点:

canvas.translate(cx, cy)
canvas.rotate(45f)
canvas.translate(-cx, -cy)

3. scale(x, y)

  • 功能:缩放 Canvas 的坐标系。

  • 作用

    • 将坐标系按照 xy 方向分别缩放指定倍数。
    • 后续绘制的内容会按照缩放后的坐标系大小进行缩放。
  • 用法

    canvas.scale(2f, 1.5f)
    

    上述代码将 x 方向放大 2 倍,y 方向放大 1.5 倍。

  • 特点

    • 默认缩放中心是原点 (0, 0),如果需要绕特定点缩放,可以先平移原点:

      canvas.translate(cx, cy)
      canvas.scale(2f, 2f)
      canvas.translate(-cx, -cy)
      

4. skew(x, y)

  • 功能:倾斜 Canvas 的坐标系。

  • 作用

    • 将坐标系按 xy 方向分别倾斜指定比例。
    • 会导致绘制的内容产生斜切变换。
  • 用法

    canvas.skew(0.5f, 0.5f)
    

    上述代码会让 x 方向倾斜 0.5 倍,y 方向倾斜 0.5 倍。

  • 特点

    • x 倾斜是指 x 方向的坐标随着 y 值的增加按比例变化。
    • y 倾斜是指 y 方向的坐标随着 x 值的增加按比例变化。
    • 常用于创造 3D 效果或仿射变换。

总结

方法作用影响内容常见用途
translate移动坐标系位置改变绘制的起点
rotate旋转坐标系位置和方向绘制旋转图形
scale缩放坐标系尺寸放大或缩小绘制内容
skew倾斜坐标系形状(斜切变换)制造透视效果或特殊形状

这些方法可以组合使用,通过先后顺序叠加各种变换,生成复杂的效果。需要注意变换会累积,所以通常在变换前后使用 saverestore 保持状态一致。

关于多重变换: Cavas的绘制过程中,Cavas的坐标是会变化的,因此,如果我们以图片为准,以倒着的方式写绘制的逻辑更加合适(针对图片,而不是针对坐标,采用正向思考,但是倒着写代码,也可以先把代码正向写完,然后再重新排列顺序,后边的放到前边,只需要把draw的代码放到最后就行了)。将 Canvas 的变换理解为 Canvas 的坐标系不变,每次变换是只对内部的绘制内容进行变换,同时把Canvas 的变换顺序看作是倒序的(即写在下⾯的变换先执⾏),可以更加⽅便进⾏多重变换的参数计算。

Matrix 的⼏何变换

preTranslate(x, y) / postTranslate(x, y)
preRotate(degree) / postRotate(degree)
preScale(x, y) / postScale(x, y)
preSkew(x, y) / postSkew(x, y)

其中 preXxx() 效果和 Canvas 的准同名⽅法相同, postXxx() 效果和 Canvas的准同名⽅法顺 序相反。如果多次绘制时重复使用 Matrix,在使用之前需要⽤ Matrix.reset() 来把 Matrix 重置。

使⽤ Camera 做三维旋转

rotate() / rotateX() / rotateY() / rotateZ()
translate()
setLocation()

其中,⼀般只⽤ rotateX() 和 rorateY() 来做沿 x 轴或 y 轴的旋转,以及使⽤ setLocation() 来调整放缩的视觉幅度。对 Camera 变换之后,要⽤ Camera.applyToCanvas(Canvas) 来应⽤到 Canvas。setLocation()这个⽅法⼀般前两个参数都填 0,第三个参数为负值。由于这个值的单位是硬编码写死的,因此像素密 度越⾼的⼿机,相当于Camera 距离View 越近,所以最好把这个值写成与机器的 density 成正⽐的⼀ 个负值,例如 -6 * density。

Camera 类可以用来进行 3D 旋转、平移等变换,常用于实现视图的三维效果。Camera 提供了多种方法来进行三维变换,使得应用能够创建更复杂的视觉效果,比如旋转、平移、缩放等。

常用方法说明:

1. rotate()

  • 作用:用于旋转 Camera 绘制的对象,旋转的角度以度为单位。

  • 说明:它对所有的三维坐标轴进行旋转,默认为绕 Z 轴旋转。

  • 用法

    camera.rotate(degree);  // 绕 Z 轴旋转
    

2. rotateX()

  • 作用:绕 X 轴旋转三维对象。

  • 说明:这会改变物体的纵深效果,模拟物体绕水平轴旋转的效果。

  • 用法

    camera.rotateX(degree);  // 绕 X 轴旋转
    

3. rotateY()

  • 作用:绕 Y 轴旋转三维对象。

  • 说明:这会改变物体的宽度效果,模拟物体绕垂直轴旋转的效果。

  • 用法

    camera.rotateY(degree);  // 绕 Y 轴旋转
    

4. rotateZ()

  • 作用:绕 Z 轴旋转三维对象。

  • 说明:这会改变物体的前后效果,模拟物体绕屏幕的轴旋转的效果。通常这个方法用于旋转二维平面中的物体。

  • 用法

    camera.rotateZ(degree);  // 绕 Z 轴旋转
    

5. translate(x, y, z)

  • 作用:用于平移物体到指定的三维坐标。

  • 说明:它接受 x, y, z 参数,用来改变物体在三维空间中的位置。

  • 用法

    camera.translate(x, y, z);  // 平移
    
    • x:沿着 X 轴平移。
    • y:沿着 Y 轴平移。
    • z:沿着 Z 轴平移,通常用于模拟物体远离或接近视点。

6. setLocation(x, y, z)

  • 作用:设置 Camera 的位置。

  • 说明:这与 translate() 不同,setLocation() 用来设置相机本身的位置,影响的是视点的位置,而非绘制的对象的位置。

  • 用法

    camera.setLocation(x, y, z);  // 设置 Camera 的位置
    
    • x, y, z:指定相机在三维空间中的位置。此操作影响相机的视角,而不是物体本身的位置。

示例:使用 Camera 进行三维旋转

例1,通过 Camera 对某个 View 进行三维旋转,可以使用以下代码来实现:

private void rotate3DView(Canvas canvas, Bitmap bitmap, Paint paint) {
    // 创建一个 Camera 对象
    Camera camera = new Camera();

    // 保存当前画布状态
    canvas.save();

    // 将画布移至中心点
    canvas.translate(300f, 300f);

    // 使用 Camera 旋转方法进行旋转
    camera.rotateX(30f);  // 绕 X 轴旋转
    camera.rotateY(60f);  // 绕 Y 轴旋转

    // 将 Camera 的变换应用到画布
    camera.applyToCanvas(canvas);

    // 画出 Bitmap
    canvas.drawBitmap(bitmap, -bitmap.getWidth() / 2f, -bitmap.getHeight() / 2f, paint);

    // 恢复画布状态
    canvas.restore();
}

代码说明:

camera.rotateX(30f); :绕 X 轴旋转 30 度。 camera.rotateY(60f); :绕 Y 轴旋转 60 度。 camera.applyToCanvas(canvas); :将 Camera 的变换应用到当前画布。这样,后续的绘制操作会受这些三维变换的影响。 canvas.drawBitmap(bitmap, -bitmap.getWidth() / 2f, -bitmap.getHeight() / 2f, paint); :在变换后的画布上绘制 Bitmap,这时 Bitmap 会呈现出三维旋转效果。 例2,对一张图片的部分区域进行旋转,实现类似的翻页效果: :

class CameraView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    // 创建一个画笔对象,设置抗锯齿
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    // 获取一个图像用于绘制
    private val bitmap = getAvatar(BITMAP_SIZE.toInt())

    /**
     * 用来实现投影的一个类,通常作用于Z轴上的变换。
     * Camera 类允许你在三维空间中对画布进行旋转和平移。
     */
    private val camera = Camera()

    init {
        // 在初始化时,绕X轴旋转30度,模拟一个投影效果
        camera.rotateX(30f)
        // 通过设置 setLocation,可以调整 camera 的位置,以实现更深层次的投影效果(注释掉了)
        // camera.setLocation(0f, 0f, -6 * resources.displayMetrics.density)
    }

    // 重写 onDraw 方法来进行绘制操作
    override fun onDraw(canvas: Canvas) {
        // 在绘制上半部分时,先保存当前 canvas 状态
        canvas.save()

        // 将画布平移,使得投影从图像的正中心开始
        canvas.translate(BITMAP_PADDING + BITMAP_SIZE / 2, BITMAP_PADDING + BITMAP_SIZE / 2)
        
        // 旋转画布 -30度,用来模拟图像的倾斜效果
        canvas.rotate(-30f)
        // 裁剪画布,只保留上半部分区域(从 -BITMAP_SIZE 到 0 的矩形区域)
        canvas.clipRect(-BITMAP_SIZE, -BITMAP_SIZE, BITMAP_SIZE, 0f)

        // 旋转画布回原来的角度
        canvas.rotate(30f)

        // 将画布移回原位置,这样就能将图像绘制在正确的地方
        canvas.translate(-(BITMAP_PADDING + BITMAP_SIZE / 2), -(BITMAP_PADDING + BITMAP_SIZE / 2))

        // 绘制图像
        canvas.drawBitmap(bitmap, BITMAP_PADDING, BITMAP_PADDING, paint)

        // 恢复之前保存的 canvas 状态,准备绘制下半部分
        canvas.restore()

        // 下半部分的绘制开始
        canvas.save()

        // 同样的方式,将画布平移到图像的正中心
        canvas.translate(BITMAP_PADDING + BITMAP_SIZE / 2, BITMAP_PADDING + BITMAP_SIZE / 2)

        // 旋转画布 -30度
        canvas.rotate(-30f)

        // 应用 camera 的投影效果
        camera.applyToCanvas(canvas)

        // 裁剪画布,只保留下半部分区域(从 0 到 BITMAP_SIZE 的矩形区域)
        canvas.clipRect(-BITMAP_SIZE, 0f, BITMAP_SIZE, BITMAP_SIZE)

        // 旋转画布回原来的角度
        canvas.rotate(30f)

        // 将画布移回原位置
        canvas.translate(-(BITMAP_PADDING + BITMAP_SIZE / 2), -(BITMAP_PADDING + BITMAP_SIZE / 2))

        // 绘制图像
        canvas.drawBitmap(bitmap, BITMAP_PADDING, BITMAP_PADDING, paint)

        // 恢复 canvas 状态
        canvas.restore()
    }

    // 获取指定宽度的头像图像
    private fun getAvatar(width: Int): Bitmap {
        // 创建 BitmapFactory.Options 对象,设置只获取图像的宽高
        val options = BitmapFactory.Options()
        options.inJustDecodeBounds = true
        // 读取资源文件,但不真正解码,获取图像的宽高
        BitmapFactory.decodeResource(resources, R.drawable.avatar_rengwuxian, options)
        
        // 重新设置 inJustDecodeBounds 为 false,准备进行实际解码
        options.inJustDecodeBounds = false
        
        // 设置目标密度,用来按指定宽度缩放图像
        options.inDensity = options.outWidth
        options.inTargetDensity = width
        
        // 解码资源文件,返回指定宽度的 Bitmap
        return BitmapFactory.decodeResource(resources, R.drawable.avatar_rengwuxian, options)
    }
}

}

上边的代码实现的是沿着箭头所指向的线条对下边进行旋转,结果如下:

image.png

原图:

image.png

我们再进行旋转等效果变化的时候,实际上canvas的坐标系也会变化,这样转来转去容易头晕,那么我们如何解决这个问题呢?就是顺着想,倒着写,如何理解这个话?我们试想,如果canvas的坐标系不变化,是不是做很多变化都能比较容易的实现了,但是实际上不是这样的,所以我们在写代码的时候,比如上边的这种效果,对于上半部分,左上平移->正向旋转->裁剪->逆向旋转->右前平移->绘制,不过我们写代码的时候,需要按照 右前平移->逆向旋转->裁剪->正向旋转->左上平移->绘制,可以看到,我们写代码与实际思考的方式是倒序的,这样就能避免canvas坐标变化而导致的问题。

注意事项:

  • Camera 的变换是相对于相机位置和视点的,因此 setLocation() 可以用来设置视点的位置,改变相机的观察角度。

  • 对于 rotate()rotateX()rotateY()rotateZ(),它们会影响绘制的对象,尤其在做复杂的动画效果时,需要灵活使用。

  • Camera 变换一般用于给 2D 图形添加 3D 效果,且它主要通过画布的变换来实现,不会改变对象本身的坐标。

  • 不使用 Camera 或不设置 camera.setLocation() 时,图像会保持原始大小,旋转只改变物体的方向。

  • 使用 Camera 并设置 camera.setLocation() 后,图像的大小会因为相机位置的变化而发生变化,尤其是相机靠近或远离物体时,物体的投影会变小或变大。这是因为实际是在操作一个三维空间中的物体,而相机位置的变化影响了它在二维屏幕上的投影。

学后检测

一、单选题

  1. 关于 canvas.clipPath() 的裁切效果,以下哪项说法正确

    • A. 边缘总是带有抗锯齿效果
    • B. 对应区域内的内容会被绘制,外部内容被裁剪掉
    • C. clipPath() 只能用于矩形区域
    • D. clipPath() 支持动态软边裁剪

    答案:B
    解析:clipPath 是硬裁剪,区域外全部被裁剪,边缘无抗锯齿。

  2. 以下关于 canvas.rotate(45f) 的效果描述,哪项是错误的

    • A. 会将 canvas 当前坐标系顺时针旋转 45°
    • B. 绘制内容的形状不会变,只有坐标轴变了
    • C. 默认绕 (0,0) 原点旋转
    • D. 绘制内容会绕自身中心旋转

    答案:D
    解析:不指定原点时旋转中心是 (0,0),不是内容自身中心。


二、多选题

  1. 关于 Canvas 几何变换,下列哪些描述是正确的

    • A. translate 可以改变后续绘制的参考点
    • B. 多次 scale 会累积,最终缩放是所有 scale 的乘积
    • C. skew 只能用于 X 方向的斜切
    • D. 变换顺序影响最终效果

    答案:A、B、D
    解析:C 错在 skew 既可 x 也可 y,顺序极其重要。

  2. 关于 Camera 的使用,下列哪些说法正确?

    • A. Camera 可以让 2D 图形产生 3D 立体感
    • B. setLocation 可改变投影视觉深度
    • C. Camera 的变换需要 applyToCanvas 才生效
    • D. Camera 只能旋转 Z 轴

    答案:A、B、C
    解析:D 错,Camera 可 rotateX/Y/Z。


三、判断题

  1. ( ) 使用 canvas.clipPath() 裁剪一个圆形区域时,抗锯齿效果完全取决于 Paint 的 isAntiAlias 属性。

    答案:错
    解析:clipPath 是像素级裁剪,本质不支持抗锯齿,Paint 的抗锯齿属性不影响 clipPath 的裁剪边缘。

  2. ( ) 多次调用 canvas.save() 后,需要同样次数的 canvas.restore() 保证状态不混乱。

    答案:对
    解析:save/restore 必须成对,否则变换栈混乱。


四、简答题

  1. 简述 clipPath 产生锯齿的本质原因,并列举两种实现平滑圆形裁切边缘的方法。

    参考答案

    • 本质原因是 clipPath 属于像素级“硬裁切”,直接舍弃不在区域内的像素,边界没有过渡,所以锯齿明显。
    • 方法一:用 Paint 绘制带抗锯齿属性的 Path 遮罩覆盖非圆区域(实现软遮罩)。
    • 方法二:直接 drawCircle 绘制白色底圆实现圆形区域填充,而不用裁剪。

五、编程题

  1. 编写一个自定义 View,实现图片的上半部分正常显示,下半部分绕 X 轴旋转 30 度显示,要求使用 Camera 和 clipRect 实现,并简单注释主要步骤。

    参考实现(伪代码+关键说明):

    class RotateHalfView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
        private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
        private val bitmap = ... // your bitmap
        private val camera = Camera()
        private val bitmapWidth = bitmap.width
        private val bitmapHeight = bitmap.height
    
        override fun onDraw(canvas: Canvas) {
            // 上半部分
            canvas.save()
            canvas.clipRect(0, 0, bitmapWidth, bitmapHeight / 2)
            canvas.drawBitmap(bitmap, 0f, 0f, paint)
            canvas.restore()
    
            // 下半部分 + 3D 旋转
            canvas.save()
            canvas.translate(bitmapWidth / 2f, bitmapHeight / 2f)
            camera.save()
            camera.rotateX(30f)
            camera.applyToCanvas(canvas)
            camera.restore()
            canvas.clipRect(-bitmapWidth / 2f, 0f, bitmapWidth / 2f, bitmapHeight / 2f)
            canvas.translate(-bitmapWidth / 2f, -bitmapHeight / 2f)
            canvas.drawBitmap(bitmap, 0f, 0f, paint)
            canvas.restore()
        }
    }
    

    解析:关键点是 clipRect+Camera 旋转的配合,以及注意变换中心与顺序。