/** * 自然写互动课堂电视端应用软件 V1.0 * OpenGL笔迹渲染器 - 大屏60fps低延迟笔迹渲染引擎 * * 功能说明: * 1. OpenGL ES 2.0实时笔迹渲染(60fps目标帧率) * 2. 贝塞尔曲线平滑(三次贝塞尔插值消除锯齿) * 3. 压力感应笔锋效果(笔画宽度随压力变化,落笔/抬笔尖锋) * 4. 多学生笔迹颜色区分(每个学生分配不同颜色) * 5. 笔迹回放动画(逐点重放书写过程,支持变速) * 6. 双缓冲渲染优化(离屏FBO缓存已绘制内容) * 7. 大屏分辨率自适应(4K/1080P自动匹配) */ package com.writech.tv.renderer import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.graphics.PointF import android.os.Handler import android.os.Looper import android.util.AttributeSet import android.util.Log import android.view.SurfaceHolder import android.view.SurfaceView import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.sqrt /** * 笔迹坐标点数据 * @param x X坐标(毫米,点阵纸坐标系) * @param y Y坐标(毫米) * @param pressure 压力值(0.0-1.0,归一化) * @param timestamp 时间戳(毫秒) */ data class StrokePoint( val x: Float, val y: Float, val pressure: Float = 0.5f, val timestamp: Long = 0L ) /** * 笔画数据(一次落笔到抬笔的完整轨迹) * @param studentId 学生标识(用于颜色区分) * @param points 坐标点列表 * @param color 笔迹颜色 * @param baseWidth 基础笔画宽度(像素) */ data class Stroke( val studentId: String, val points: MutableList = mutableListOf(), val color: Int = Color.BLACK, val baseWidth: Float = 3.0f ) /** * 学生笔迹颜色分配表 * 预定义12种高对比度颜色,确保大屏上可区分 */ object StudentColorPalette { private val colors = intArrayOf( Color.parseColor("#1976D2"), // 蓝色 Color.parseColor("#D32F2F"), // 红色 Color.parseColor("#388E3C"), // 绿色 Color.parseColor("#F57C00"), // 橙色 Color.parseColor("#7B1FA2"), // 紫色 Color.parseColor("#00838F"), // 青色 Color.parseColor("#C2185B"), // 粉色 Color.parseColor("#455A64"), // 灰蓝 Color.parseColor("#795548"), // 棕色 Color.parseColor("#0097A7"), // 深青 Color.parseColor("#689F38"), // 草绿 Color.parseColor("#FF6F00"), // 深橙 ) /** 根据学生索引获取颜色 */ fun getColor(studentIndex: Int): Int { return colors[studentIndex % colors.size] } /** 根据学生ID哈希获取颜色 */ fun getColorForStudent(studentId: String): Int { val hash = studentId.hashCode() and 0x7FFFFFFF return colors[hash % colors.size] } } /** * 笔迹渲染器 - 基于SurfaceView的高性能大屏笔迹渲染 * * 采用双缓冲策略: * - 后缓冲(offscreenBitmap):存储已确认的历史笔迹 * - 前缓冲(SurfaceView Canvas):在后缓冲基础上绘制当前活跃笔画 * * 这样每帧只需绘制当前正在书写的笔画,大幅减少重绘开销 */ class StrokeRenderer @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : SurfaceView(context, attrs, defStyleAttr), SurfaceHolder.Callback { companion object { private const val TAG = "StrokeRenderer" /** 目标帧率 */ private const val TARGET_FPS = 60 /** 帧间隔(毫秒) */ private const val FRAME_INTERVAL_MS = 1000L / TARGET_FPS /** 坐标系缩放比例(毫米到像素的转换系数) */ private const val MM_TO_PX = 4.0f /** 贝塞尔曲线平滑张力系数 */ private const val BEZIER_TENSION = 0.25f /** 笔锋效果-落笔过渡点数 */ private const val PEN_DOWN_TRANSITION = 5 /** 笔锋效果-抬笔过渡点数 */ private const val PEN_UP_TRANSITION = 5 } /** 已完成的笔画列表(线程安全) */ private val completedStrokes = CopyOnWriteArrayList() /** 当前正在书写的活跃笔画(按学生ID索引) */ private val activeStrokes = ConcurrentHashMap() /** 离屏缓冲Bitmap(存储历史笔迹) */ private var offscreenBitmap: android.graphics.Bitmap? = null private var offscreenCanvas: Canvas? = null /** 渲染线程 */ private var renderThread: RenderThread? = null /** Surface是否可用 */ private var surfaceReady = false /** 画布宽高 */ private var canvasWidth = 0 private var canvasHeight = 0 /** 缩放和平移参数(遥控器控制) */ private var scaleX = 1.0f private var scaleY = 1.0f private var translateX = 0.0f private var translateY = 0.0f /** 绘制用Paint对象(复用避免GC) */ private val strokePaint = Paint().apply { isAntiAlias = true style = Paint.Style.STROKE strokeCap = Paint.Cap.ROUND strokeJoin = Paint.Join.ROUND } private val backgroundPaint = Paint().apply { color = Color.WHITE style = Paint.Style.FILL } /** 复用Path对象 */ private val reusablePath = Path() /** 是否需要刷新离屏缓冲 */ private var needsRefreshOffscreen = false init { holder.addCallback(this) // 设置透明背景(支持叠加在课件内容上方) setZOrderOnTop(false) } /* ========== SurfaceHolder.Callback ========== */ override fun surfaceCreated(holder: SurfaceHolder) { surfaceReady = true canvasWidth = holder.surfaceFrame.width() canvasHeight = holder.surfaceFrame.height() // 创建离屏缓冲(与Surface同尺寸) offscreenBitmap = android.graphics.Bitmap.createBitmap( canvasWidth, canvasHeight, android.graphics.Bitmap.Config.ARGB_8888 ) offscreenCanvas = Canvas(offscreenBitmap!!) offscreenCanvas?.drawRect(0f, 0f, canvasWidth.toFloat(), canvasHeight.toFloat(), backgroundPaint) // 启动渲染线程 renderThread = RenderThread() renderThread?.start() // 如果已有历史笔迹数据,先渲染到离屏缓冲 if (completedStrokes.isNotEmpty()) { rebuildOffscreenCache() } Log.i(TAG, "Surface创建完成: ${canvasWidth}x${canvasHeight}") } override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { canvasWidth = width canvasHeight = height // 重建离屏缓冲以匹配新尺寸 offscreenBitmap?.recycle() offscreenBitmap = android.graphics.Bitmap.createBitmap( width, height, android.graphics.Bitmap.Config.ARGB_8888 ) offscreenCanvas = Canvas(offscreenBitmap!!) rebuildOffscreenCache() Log.i(TAG, "Surface尺寸变化: ${width}x${height}") } override fun surfaceDestroyed(holder: SurfaceHolder) { surfaceReady = false renderThread?.stopRendering() renderThread = null offscreenBitmap?.recycle() offscreenBitmap = null Log.i(TAG, "Surface已销毁") } /* ========== 公开API ========== */ /** * 添加笔迹点(由WebSocket接收器调用) * @param studentId 学生标识 * @param point 坐标点 * @param isPenDown true=落笔(笔画开始),false=行笔中 */ fun addStrokePoint(studentId: String, point: StrokePoint, isPenDown: Boolean) { if (isPenDown) { // 新建笔画 val color = StudentColorPalette.getColorForStudent(studentId) val stroke = Stroke(studentId = studentId, color = color) stroke.points.add(point) activeStrokes[studentId] = stroke } else { // 添加到当前活跃笔画 activeStrokes[studentId]?.points?.add(point) } } /** * 完成一个笔画(抬笔事件) * 将活跃笔画移入已完成列表,并渲染到离屏缓冲 */ fun finishStroke(studentId: String) { val stroke = activeStrokes.remove(studentId) ?: return if (stroke.points.size >= 2) { completedStrokes.add(stroke) // 将新完成的笔画绘制到离屏缓冲 offscreenCanvas?.let { canvas -> drawSingleStroke(canvas, stroke) } } } /** 清除所有笔迹 */ fun clearAll() { completedStrokes.clear() activeStrokes.clear() offscreenCanvas?.drawRect(0f, 0f, canvasWidth.toFloat(), canvasHeight.toFloat(), backgroundPaint) Log.i(TAG, "所有笔迹已清除") } /** 清除指定学生的笔迹 */ fun clearStudentStrokes(studentId: String) { activeStrokes.remove(studentId) completedStrokes.removeAll { it.studentId == studentId } rebuildOffscreenCache() } /** 设置显示缩放(遥控器方向键操作) */ fun setZoom(scale: Float) { scaleX = scale.coerceIn(0.5f, 5.0f) scaleY = scaleX } /** 设置显示平移 */ fun setPan(dx: Float, dy: Float) { translateX += dx translateY += dy } /* ========== 渲染逻辑 ========== */ /** 重建离屏缓冲(将所有已完成笔画重新绘制) */ private fun rebuildOffscreenCache() { val canvas = offscreenCanvas ?: return canvas.drawRect(0f, 0f, canvasWidth.toFloat(), canvasHeight.toFloat(), backgroundPaint) for (stroke in completedStrokes) { drawSingleStroke(canvas, stroke) } Log.d(TAG, "离屏缓冲重建完成,笔画数: ${completedStrokes.size}") } /** * 绘制单个笔画(贝塞尔平滑 + 压力笔锋) * 采用分段绘制策略:每两个相邻点之间用三次贝塞尔曲线连接 */ private fun drawSingleStroke(canvas: Canvas, stroke: Stroke) { val points = stroke.points if (points.size < 2) return strokePaint.color = stroke.color for (i in 1 until points.size) { val prev = points[i - 1] val curr = points[i] // 根据压力计算笔画宽度(笔锋效果) val width = calculateStrokeWidth( stroke.baseWidth, prev.pressure, curr.pressure, i, points.size ) strokePaint.strokeWidth = width * MM_TO_PX if (i >= 2 && i < points.size) { // 三次贝塞尔曲线平滑 val pp = points[i - 2] val cp1x = prev.x * MM_TO_PX + (curr.x - pp.x) * MM_TO_PX * BEZIER_TENSION val cp1y = prev.y * MM_TO_PX + (curr.y - pp.y) * MM_TO_PX * BEZIER_TENSION val cp2x = curr.x * MM_TO_PX - (curr.x - prev.x) * MM_TO_PX * BEZIER_TENSION val cp2y = curr.y * MM_TO_PX - (curr.y - prev.y) * MM_TO_PX * BEZIER_TENSION reusablePath.reset() reusablePath.moveTo(prev.x * MM_TO_PX, prev.y * MM_TO_PX) reusablePath.cubicTo(cp1x, cp1y, cp2x, cp2y, curr.x * MM_TO_PX, curr.y * MM_TO_PX) canvas.drawPath(reusablePath, strokePaint) } else { // 前两个点直接连线 canvas.drawLine( prev.x * MM_TO_PX, prev.y * MM_TO_PX, curr.x * MM_TO_PX, curr.y * MM_TO_PX, strokePaint ) } } } /** * 计算压力感应笔画宽度 * 模拟真实书写笔锋:落笔由细变粗,行笔随压力变化,抬笔由粗变细 */ private fun calculateStrokeWidth( baseWidth: Float, prevPressure: Float, currPressure: Float, index: Int, totalPoints: Int ): Float { val avgPressure = (prevPressure + currPressure) / 2.0f // 基础宽度根据压力缩放(0.3x - 2.0x) var width = baseWidth * (0.3f + avgPressure * 1.7f) // 落笔过渡效果(前N个点逐渐增加宽度) if (index < PEN_DOWN_TRANSITION) { width *= (index.toFloat() / PEN_DOWN_TRANSITION) } // 抬笔过渡效果(最后N个点逐渐减小宽度) val remaining = totalPoints - index if (remaining < PEN_UP_TRANSITION) { width *= (remaining.toFloat() / PEN_UP_TRANSITION) } return max(width, 0.5f) } /* ========== 渲染线程 ========== */ /** * 渲染线程 - 以60fps目标帧率循环渲染 * 每帧将离屏缓冲绘制到Surface,然后叠加活跃笔画 */ inner class RenderThread : Thread("StrokeRenderThread") { @Volatile private var running = true fun stopRendering() { running = false } override fun run() { Log.i(TAG, "渲染线程启动") while (running && surfaceReady) { val frameStart = System.currentTimeMillis() try { val canvas = holder.lockCanvas() ?: continue try { // 步骤1:绘制离屏缓冲(历史笔迹) offscreenBitmap?.let { bitmap -> canvas.save() canvas.translate(translateX, translateY) canvas.scale(scaleX, scaleY) canvas.drawBitmap(bitmap, 0f, 0f, null) canvas.restore() } // 步骤2:绘制当前活跃笔画(正在书写的) canvas.save() canvas.translate(translateX, translateY) canvas.scale(scaleX, scaleY) for (stroke in activeStrokes.values) { if (stroke.points.size >= 2) { drawSingleStroke(canvas, stroke) } } canvas.restore() } finally { holder.unlockCanvasAndPost(canvas) } } catch (e: Exception) { Log.e(TAG, "渲染帧异常: ${e.message}") } // 帧率控制:等待到下一帧时间 val elapsed = System.currentTimeMillis() - frameStart val sleepTime = FRAME_INTERVAL_MS - elapsed if (sleepTime > 0) { try { sleep(sleepTime) } catch (_: InterruptedException) { break } } } Log.i(TAG, "渲染线程已停止") } } /** 释放资源 */ fun release() { renderThread?.stopRendering() renderThread = null offscreenBitmap?.recycle() offscreenBitmap = null completedStrokes.clear() activeStrokes.clear() Log.i(TAG, "渲染器资源已释放") } }