mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
Fixed Square Brush, Improve Brush Hardness and Smoothing Precision (#4519)
* Fixed square brush with hardness <1; improved the effect of hardness, improved the effect of smoothing precision * Improved square hardness and code quality with performance optimizations * Fix brush rendering anti-aliasing and optimized square brushes using texture caching * Switched to QuickLRU for brush cache * Cleaned up exports from testing * Removed SOFT_BRUSH_STEPS unused variable
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import _ from 'es-toolkit/compat'
|
||||
|
||||
@@ -9,6 +10,7 @@ import { ComfyApp } from '../../scripts/app'
|
||||
import { $el, ComfyDialog } from '../../scripts/ui'
|
||||
import { getStorageValue, setStorageValue } from '../../scripts/utils'
|
||||
import { hexToRgb } from '../../utils/colorUtil'
|
||||
import { parseToRgb } from '../../utils/colorUtil'
|
||||
import { ClipspaceDialog } from './clipspace'
|
||||
import {
|
||||
imageLayerFilenamesByTimestamp,
|
||||
@@ -811,7 +813,7 @@ interface Offset {
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Brush {
|
||||
interface Brush {
|
||||
type: BrushShape
|
||||
size: number
|
||||
opacity: number
|
||||
@@ -2049,9 +2051,16 @@ class BrushTool {
|
||||
rgbCtx: CanvasRenderingContext2D | null = null
|
||||
initialDraw: boolean = true
|
||||
|
||||
private static brushTextureCache = new QuickLRU<string, HTMLCanvasElement>({
|
||||
maxSize: 8 // Reasonable limit for brush texture variations?
|
||||
})
|
||||
|
||||
brushStrokeCanvas: HTMLCanvasElement | null = null
|
||||
brushStrokeCtx: CanvasRenderingContext2D | null = null
|
||||
|
||||
private static readonly SMOOTHING_MAX_STEPS = 30
|
||||
private static readonly SMOOTHING_MIN_STEPS = 2
|
||||
|
||||
//brush adjustment
|
||||
isBrushAdjusting: boolean = false
|
||||
brushPreviewGradient: HTMLElement | null = null
|
||||
@@ -2254,6 +2263,10 @@ class BrushTool {
|
||||
}
|
||||
}
|
||||
|
||||
private clampSmoothingPrecision(value: number): number {
|
||||
return Math.min(Math.max(value, 1), 100)
|
||||
}
|
||||
|
||||
private drawWithBetterSmoothing(point: Point) {
|
||||
// Add current point to the smoothing array
|
||||
if (!this.smoothingCordsArray) {
|
||||
@@ -2285,9 +2298,21 @@ class BrushTool {
|
||||
totalLength += Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
const distanceBetweenPoints =
|
||||
(this.brushSettings.size / this.brushSettings.smoothingPrecision) * 6
|
||||
const stepNr = Math.ceil(totalLength / distanceBetweenPoints)
|
||||
const maxSteps = BrushTool.SMOOTHING_MAX_STEPS
|
||||
const minSteps = BrushTool.SMOOTHING_MIN_STEPS
|
||||
|
||||
const smoothing = this.clampSmoothingPrecision(
|
||||
this.brushSettings.smoothingPrecision
|
||||
)
|
||||
const normalizedSmoothing = (smoothing - 1) / 99 // Convert to 0-1 range
|
||||
|
||||
// Optionality to use exponential curve
|
||||
const stepNr = Math.round(
|
||||
Math.round(minSteps + (maxSteps - minSteps) * normalizedSmoothing)
|
||||
)
|
||||
|
||||
// Calculate step distance capped by brush size
|
||||
const distanceBetweenPoints = totalLength / stepNr
|
||||
|
||||
let interpolatedPoints = points
|
||||
|
||||
@@ -2435,101 +2460,205 @@ class BrushTool {
|
||||
const hardness = brushSettings.hardness
|
||||
const x = point.x
|
||||
const y = point.y
|
||||
// Extend the gradient radius beyond the brush size
|
||||
const extendedSize = size * (2 - hardness)
|
||||
|
||||
const brushRadius = size
|
||||
const isErasing = maskCtx.globalCompositeOperation === 'destination-out'
|
||||
const currentTool = await this.messageBroker.pull('currentTool')
|
||||
|
||||
// handle paint pen
|
||||
// Helper function to get or create cached brush texture
|
||||
const getCachedBrushTexture = (
|
||||
radius: number,
|
||||
hardness: number,
|
||||
color: string,
|
||||
opacity: number
|
||||
): HTMLCanvasElement => {
|
||||
const cacheKey = `${radius}_${hardness}_${color}_${opacity}`
|
||||
|
||||
if (BrushTool.brushTextureCache.has(cacheKey)) {
|
||||
return BrushTool.brushTextureCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
const tempCanvas = document.createElement('canvas')
|
||||
const tempCtx = tempCanvas.getContext('2d')!
|
||||
const size = radius * 2
|
||||
tempCanvas.width = size
|
||||
tempCanvas.height = size
|
||||
|
||||
const centerX = size / 2
|
||||
const centerY = size / 2
|
||||
const hardRadius = radius * hardness
|
||||
|
||||
const imageData = tempCtx.createImageData(size, size)
|
||||
const data = imageData.data
|
||||
const { r, g, b } = parseToRgb(color)
|
||||
|
||||
// Pre-calculate values to avoid repeated computations
|
||||
const fadeRange = radius - hardRadius
|
||||
|
||||
for (let y = 0; y < size; y++) {
|
||||
const dy = y - centerY
|
||||
for (let x = 0; x < size; x++) {
|
||||
const dx = x - centerX
|
||||
const index = (y * size + x) * 4
|
||||
|
||||
// Calculate square distance (Chebyshev distance)
|
||||
const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy))
|
||||
|
||||
let pixelOpacity = 0
|
||||
if (distFromEdge <= hardRadius) {
|
||||
pixelOpacity = opacity
|
||||
} else if (distFromEdge <= radius) {
|
||||
const fadeProgress = (distFromEdge - hardRadius) / fadeRange
|
||||
pixelOpacity = opacity * (1 - fadeProgress)
|
||||
}
|
||||
|
||||
data[index] = r
|
||||
data[index + 1] = g
|
||||
data[index + 2] = b
|
||||
data[index + 3] = pixelOpacity * 255
|
||||
}
|
||||
}
|
||||
|
||||
tempCtx.putImageData(imageData, 0, 0)
|
||||
|
||||
// Cache the texture
|
||||
BrushTool.brushTextureCache.set(cacheKey, tempCanvas)
|
||||
|
||||
return tempCanvas
|
||||
}
|
||||
|
||||
// RGB brush logic
|
||||
if (
|
||||
this.activeLayer === 'rgb' &&
|
||||
(currentTool === Tools.Eraser || currentTool === Tools.PaintPen)
|
||||
) {
|
||||
const rgbaColor = this.formatRgba(this.rgbColor, opacity)
|
||||
let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, extendedSize)
|
||||
if (hardness === 1) {
|
||||
gradient.addColorStop(0, rgbaColor)
|
||||
gradient.addColorStop(
|
||||
1,
|
||||
this.formatRgba(this.rgbColor, brushSettingsSliderOpacity)
|
||||
|
||||
if (brushType === BrushShape.Rect && hardness < 1) {
|
||||
const brushTexture = getCachedBrushTexture(
|
||||
brushRadius,
|
||||
hardness,
|
||||
rgbaColor,
|
||||
opacity
|
||||
)
|
||||
} else {
|
||||
gradient.addColorStop(0, rgbaColor)
|
||||
gradient.addColorStop(hardness, rgbaColor)
|
||||
gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
|
||||
rgbCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
|
||||
return
|
||||
}
|
||||
|
||||
// For max hardness, use solid fill to avoid anti-aliasing
|
||||
if (hardness === 1) {
|
||||
rgbCtx.fillStyle = rgbaColor
|
||||
rgbCtx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
rgbCtx.rect(
|
||||
x - brushRadius,
|
||||
y - brushRadius,
|
||||
brushRadius * 2,
|
||||
brushRadius * 2
|
||||
)
|
||||
} else {
|
||||
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||
}
|
||||
rgbCtx.fill()
|
||||
return
|
||||
}
|
||||
|
||||
// For soft brushes, use gradient
|
||||
let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
|
||||
gradient.addColorStop(0, rgbaColor)
|
||||
gradient.addColorStop(
|
||||
hardness,
|
||||
this.formatRgba(this.rgbColor, opacity * 0.5)
|
||||
)
|
||||
gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
|
||||
|
||||
rgbCtx.fillStyle = gradient
|
||||
rgbCtx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
rgbCtx.rect(
|
||||
x - extendedSize,
|
||||
y - extendedSize,
|
||||
extendedSize * 2,
|
||||
extendedSize * 2
|
||||
x - brushRadius,
|
||||
y - brushRadius,
|
||||
brushRadius * 2,
|
||||
brushRadius * 2
|
||||
)
|
||||
} else {
|
||||
rgbCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false)
|
||||
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||
}
|
||||
rgbCtx.fill()
|
||||
return
|
||||
}
|
||||
|
||||
let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, extendedSize)
|
||||
// Mask brush logic
|
||||
if (brushType === BrushShape.Rect && hardness < 1) {
|
||||
const baseColor = isErasing
|
||||
? `rgba(255, 255, 255, ${opacity})`
|
||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
|
||||
const brushTexture = getCachedBrushTexture(
|
||||
brushRadius,
|
||||
hardness,
|
||||
baseColor,
|
||||
opacity
|
||||
)
|
||||
maskCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
|
||||
return
|
||||
}
|
||||
|
||||
// For max hardness, use solid fill to avoid anti-aliasing
|
||||
if (hardness === 1) {
|
||||
const solidColor = isErasing
|
||||
? `rgba(255, 255, 255, ${opacity})`
|
||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
|
||||
maskCtx.fillStyle = solidColor
|
||||
maskCtx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
maskCtx.rect(
|
||||
x - brushRadius,
|
||||
y - brushRadius,
|
||||
brushRadius * 2,
|
||||
brushRadius * 2
|
||||
)
|
||||
} else {
|
||||
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||
}
|
||||
maskCtx.fill()
|
||||
return
|
||||
}
|
||||
|
||||
// For soft brushes, use gradient
|
||||
let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
|
||||
|
||||
if (isErasing) {
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
|
||||
gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity * 0.5})`)
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
|
||||
} else {
|
||||
gradient.addColorStop(
|
||||
0,
|
||||
isErasing
|
||||
? `rgba(255, 255, 255, ${opacity})`
|
||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
)
|
||||
gradient.addColorStop(
|
||||
hardness,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity * 0.5})`
|
||||
)
|
||||
gradient.addColorStop(
|
||||
1,
|
||||
isErasing
|
||||
? `rgba(255, 255, 255, ${opacity})`
|
||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
|
||||
)
|
||||
} else {
|
||||
let softness = 1 - hardness
|
||||
let innerStop = Math.max(0, hardness - softness)
|
||||
let outerStop = size / extendedSize
|
||||
|
||||
if (isErasing) {
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
|
||||
gradient.addColorStop(innerStop, `rgba(255, 255, 255, ${opacity})`)
|
||||
gradient.addColorStop(outerStop, `rgba(255, 255, 255, ${opacity / 2})`)
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
|
||||
} else {
|
||||
gradient.addColorStop(
|
||||
0,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
)
|
||||
gradient.addColorStop(
|
||||
innerStop,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
)
|
||||
gradient.addColorStop(
|
||||
outerStop,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity / 2})`
|
||||
)
|
||||
gradient.addColorStop(
|
||||
1,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
maskCtx.fillStyle = gradient
|
||||
maskCtx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
maskCtx.rect(
|
||||
x - extendedSize,
|
||||
y - extendedSize,
|
||||
extendedSize * 2,
|
||||
extendedSize * 2
|
||||
x - brushRadius,
|
||||
y - brushRadius,
|
||||
brushRadius * 2,
|
||||
brushRadius * 2
|
||||
)
|
||||
} else {
|
||||
maskCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false)
|
||||
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||
}
|
||||
maskCtx.fill()
|
||||
}
|
||||
@@ -4185,30 +4314,35 @@ class UIManager {
|
||||
const centerY = cursorPoint.y + pan_offset.y
|
||||
const brush = this.brush
|
||||
const hardness = brushSettings.hardness
|
||||
const extendedSize = brushSettings.size * (2 - hardness) * 2 * zoom_ratio
|
||||
|
||||
// Now that brush size is constant, preview is simple
|
||||
const brushRadius = brushSettings.size * zoom_ratio
|
||||
const previewSize = brushRadius * 2
|
||||
|
||||
this.brushSizeSlider.value = String(brushSettings.size)
|
||||
this.brushHardnessSlider.value = String(hardness)
|
||||
|
||||
brush.style.width = extendedSize + 'px'
|
||||
brush.style.height = extendedSize + 'px'
|
||||
brush.style.left = centerX - extendedSize / 2 + 'px'
|
||||
brush.style.top = centerY - extendedSize / 2 + 'px'
|
||||
brush.style.width = previewSize + 'px'
|
||||
brush.style.height = previewSize + 'px'
|
||||
brush.style.left = centerX - brushRadius + 'px'
|
||||
brush.style.top = centerY - brushRadius + 'px'
|
||||
|
||||
if (hardness === 1) {
|
||||
this.brushPreviewGradient.style.background = 'rgba(255, 0, 0, 0.5)'
|
||||
return
|
||||
}
|
||||
|
||||
const opacityStop = hardness / 4 + 0.25
|
||||
// Simplified gradient - hardness controls where the fade starts
|
||||
const midStop = hardness * 100
|
||||
const outerStop = 100
|
||||
|
||||
this.brushPreviewGradient.style.background = `
|
||||
radial-gradient(
|
||||
circle,
|
||||
rgba(255, 0, 0, 0.5) 0%,
|
||||
rgba(255, 0, 0, ${opacityStop}) ${hardness * 100}%,
|
||||
rgba(255, 0, 0, 0) 100%
|
||||
)
|
||||
radial-gradient(
|
||||
circle,
|
||||
rgba(255, 0, 0, 0.5) 0%,
|
||||
rgba(255, 0, 0, 0.25) ${midStop}%,
|
||||
rgba(255, 0, 0, 0) ${outerStop}%
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,59 @@ export function hexToRgb(hex: string): RGB {
|
||||
return { r, g, b }
|
||||
}
|
||||
|
||||
export function parseToRgb(color: string): RGB {
|
||||
const format = identifyColorFormat(color)
|
||||
if (!format) return { r: 0, g: 0, b: 0 }
|
||||
|
||||
const hsla = parseToHSLA(color, format)
|
||||
if (!isHSLA(hsla)) return { r: 0, g: 0, b: 0 }
|
||||
|
||||
// Convert HSL to RGB
|
||||
const h = hsla.h / 360
|
||||
const s = hsla.s / 100
|
||||
const l = hsla.l / 100
|
||||
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s
|
||||
const x = c * (1 - Math.abs(((h * 6) % 2) - 1))
|
||||
const m = l - c / 2
|
||||
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0
|
||||
|
||||
if (h < 1 / 6) {
|
||||
r = c
|
||||
g = x
|
||||
b = 0
|
||||
} else if (h < 2 / 6) {
|
||||
r = x
|
||||
g = c
|
||||
b = 0
|
||||
} else if (h < 3 / 6) {
|
||||
r = 0
|
||||
g = c
|
||||
b = x
|
||||
} else if (h < 4 / 6) {
|
||||
r = 0
|
||||
g = x
|
||||
b = c
|
||||
} else if (h < 5 / 6) {
|
||||
r = x
|
||||
g = 0
|
||||
b = c
|
||||
} else {
|
||||
r = c
|
||||
g = 0
|
||||
b = x
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round((r + m) * 255),
|
||||
g: Math.round((g + m) * 255),
|
||||
b: Math.round((b + m) * 255)
|
||||
}
|
||||
}
|
||||
|
||||
const identifyColorFormat = (color: string): ColorFormat | null => {
|
||||
if (!color) return null
|
||||
if (color.startsWith('#') && (color.length === 4 || color.length === 7))
|
||||
|
||||
Reference in New Issue
Block a user